Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .claude/skills/unity-editor/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ unityctl script execute -f /tmp/SpawnObjects.cs -- Cube 5 "My Object"

Use `Main(string[] args)` to accept arguments passed after `--`.

Use `-t <seconds>` on `script eval`/`script execute` for long-running operations (default 30s):

```bash
unityctl script eval -t 300 -u UnityEditor 'return BuildPipeline.BuildPlayer(opts).summary.result.ToString();'
```

## Typical Workflow

```bash
Expand Down
12 changes: 11 additions & 1 deletion UnityCtl.Bridge/BridgeEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,10 @@ private static async Task<IResult> HandleRpcAsync(BridgeState state, HttpContext
try
{
var hasConfig = CommandConfigs.TryGetValue(request.Command, out var config);
var timeout = hasConfig ? config!.Timeout : GetDefaultTimeout();
// Request-level timeout (from caller) takes precedence over per-command config
var timeout = request.Timeout.HasValue
? TimeSpan.FromSeconds(request.Timeout.Value)
: hasConfig ? config!.Timeout : GetDefaultTimeout();

if (request.Command == UnityCtlCommands.PlayEnter)
return await HandlePlayEnterAsync(state, requestMessage, request, timeout, context.RequestAborted);
Expand Down Expand Up @@ -1229,6 +1232,13 @@ public class RpcRequest
public string? AgentId { get; set; }
public required string Command { get; set; }
public Dictionary<string, object?>? Args { get; set; }

/// <summary>
/// Optional timeout override in seconds. When set, takes precedence over
/// per-command and default timeouts. Useful for long-running script executions
/// like player builds.
/// </summary>
public int? Timeout { get; set; }
}

internal class CommandConfig
Expand Down
22 changes: 18 additions & 4 deletions UnityCtl.Cli/BridgeClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using UnityCtl.Protocol;

Expand All @@ -22,7 +23,11 @@ public BridgeClient(string baseUrl, string? agentId = null, string? projectRoot
_baseUrl = baseUrl;
_agentId = agentId;
_projectRoot = projectRoot;
_httpClient = new HttpClient { BaseAddress = new Uri(baseUrl) };
_httpClient = new HttpClient
{
BaseAddress = new Uri(baseUrl),
Timeout = System.Threading.Timeout.InfiniteTimeSpan
};
}

public static BridgeClient? TryCreateFromProject(string? projectPath, string? agentId)
Expand Down Expand Up @@ -118,21 +123,30 @@ public BridgeClient(string baseUrl, string? agentId = null, string? projectRoot
}
}

public async Task<ResponseMessage?> SendCommandAsync(string command, Dictionary<string, object?>? args = null)
public async Task<ResponseMessage?> SendCommandAsync(string command, Dictionary<string, object?>? args = null, int? timeoutSeconds = null)
{
try
{
var request = new
{
agentId = _agentId,
command = command,
args = args
args = args,
timeout = timeoutSeconds
};

var json = JsonHelper.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");

var response = await _httpClient.PostAsync("/rpc", content);
// The bridge enforces the real timeout server-side. The HTTP timeout
// just needs to be long enough to not race it. Add a 30s buffer so the
// bridge always gets to respond first (with a proper 504) rather than
// the HTTP client throwing a TaskCanceledException.
using var cts = new CancellationTokenSource();
if (timeoutSeconds.HasValue)
cts.CancelAfter(TimeSpan.FromSeconds(timeoutSeconds.Value + 30));

var response = await _httpClient.PostAsync("/rpc", content, cts.Token);

if (!response.IsSuccessStatusCode)
{
Expand Down
15 changes: 13 additions & 2 deletions UnityCtl.Cli/ScriptCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public static Command CreateCommand()
{
var scriptCommand = new Command("script", "C# script execution operations");

var timeoutOption = new Option<int?>(
aliases: ["--timeout", "-t"],
description: "Timeout in seconds (overrides the default 30s for long-running operations like player builds)"
);

// script execute
var executeCommand = new Command("execute", "Execute C# code in the Unity Editor");

Expand Down Expand Up @@ -63,6 +68,7 @@ public static Command CreateCommand()
executeCommand.AddOption(fileOption);
executeCommand.AddOption(classOption);
executeCommand.AddOption(methodOption);
executeCommand.AddOption(timeoutOption);
executeCommand.AddArgument(scriptArgsArgument);

executeCommand.SetHandler(async (InvocationContext context) =>
Expand Down Expand Up @@ -125,7 +131,9 @@ public static Command CreateCommand()
{ "scriptArgs", scriptArgs }
};

var response = await client.SendCommandAsync(UnityCtlCommands.ScriptExecute, args);
var timeout = context.ParseResult.GetValueForOption(timeoutOption);

var response = await client.SendCommandAsync(UnityCtlCommands.ScriptExecute, args, timeout);
if (response == null) { context.ExitCode = 1; return; }

if (response.Status == ResponseStatus.Error)
Expand Down Expand Up @@ -162,6 +170,7 @@ public static Command CreateCommand()

evalCommand.AddArgument(expressionArgument);
evalCommand.AddOption(usingOption);
evalCommand.AddOption(timeoutOption);
evalCommand.AddArgument(evalScriptArgsArgument);

evalCommand.SetHandler(async (InvocationContext context) =>
Expand Down Expand Up @@ -195,7 +204,9 @@ public static Command CreateCommand()
{ "scriptArgs", scriptArgs }
};

var response = await client.SendCommandAsync(UnityCtlCommands.ScriptExecute, args);
var timeout = context.ParseResult.GetValueForOption(timeoutOption);

var response = await client.SendCommandAsync(UnityCtlCommands.ScriptExecute, args, timeout);
if (response == null) { context.ExitCode = 1; return; }

if (response.Status == ResponseStatus.Error)
Expand Down
142 changes: 142 additions & 0 deletions UnityCtl.Tests/Integration/RequestTimeoutTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
using System.Net;
using System.Text;
using UnityCtl.Protocol;
using UnityCtl.Tests.Fakes;
using UnityCtl.Tests.Helpers;
using Xunit;

namespace UnityCtl.Tests.Integration;

/// <summary>
/// Tests for the request-level timeout override on RpcRequest.
/// </summary>
public class RequestTimeoutTests : IAsyncLifetime
{
private readonly BridgeTestFixture _fixture = new();

public Task InitializeAsync()
{
// Set a very short default so we can verify the override works
Environment.SetEnvironmentVariable("UNITYCTL_TIMEOUT_DEFAULT", "2");
return _fixture.InitializeAsync();
}

public async Task DisposeAsync()
{
Environment.SetEnvironmentVariable("UNITYCTL_TIMEOUT_DEFAULT", null);
await _fixture.DisposeAsync();
}

[Fact]
public async Task ScriptExecute_WithTimeoutOverride_UsesRequestTimeout()
{
// Default timeout is 2s, but the request asks for 10s.
// Unity responds in 4s — should succeed with override, fail without.
_fixture.FakeUnity.OnCommandWithDelay(
UnityCtlCommands.ScriptExecute,
TimeSpan.FromSeconds(4),
_ => new ScriptExecuteResult { Success = true, Result = "build done" });

var response = await SendRpcWithTimeoutAsync(
UnityCtlCommands.ScriptExecute,
new Dictionary<string, object?>
{
{ "code", "public class Script { public static object Main() { return \"ok\"; } }" },
{ "className", "Script" },
{ "methodName", "Main" }
},
timeoutSeconds: 10);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var responseMessage = await ParseResponseAsync(response);
Assert.Equal(ResponseStatus.Ok, responseMessage.Status);
}

[Fact]
public async Task ScriptExecute_WithoutTimeoutOverride_UsesDefault()
{
// Default timeout is 2s, Unity responds in 4s — should time out
_fixture.FakeUnity.OnCommandWithDelay(
UnityCtlCommands.ScriptExecute,
TimeSpan.FromSeconds(4),
_ => new ScriptExecuteResult { Success = true, Result = "build done" });

var response = await SendRpcWithTimeoutAsync(
UnityCtlCommands.ScriptExecute,
new Dictionary<string, object?>
{
{ "code", "public class Script { public static object Main() { return \"ok\"; } }" },
{ "className", "Script" },
{ "methodName", "Main" }
},
timeoutSeconds: null);

Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode);
}

[Fact]
public async Task ScriptExecute_ErrorResult_PropagatesWithTimeout()
{
_fixture.FakeUnity.OnCommandError(
UnityCtlCommands.ScriptExecute,
"command_failed",
"Build failed: missing scenes");

var response = await SendRpcWithTimeoutAsync(
UnityCtlCommands.ScriptExecute,
new Dictionary<string, object?>
{
{ "code", "public class Script { public static object Main() { return null; } }" },
{ "className", "Script" },
{ "methodName", "Main" }
},
timeoutSeconds: 600);

Assert.Equal(HttpStatusCode.OK, response.StatusCode);

var responseMessage = await ParseResponseAsync(response);
Assert.Equal(ResponseStatus.Error, responseMessage.Status);
Assert.Equal("command_failed", responseMessage.Error?.Code);
}

[Fact]
public async Task ScriptExecute_TimeoutOverride_StillTimesOut()
{
// Request timeout is 3s, Unity responds in 10s — should time out
_fixture.FakeUnity.OnCommandWithDelay(
UnityCtlCommands.ScriptExecute,
TimeSpan.FromSeconds(10),
_ => new ScriptExecuteResult { Success = true, Result = "done" });

var response = await SendRpcWithTimeoutAsync(
UnityCtlCommands.ScriptExecute,
new Dictionary<string, object?>
{
{ "code", "public class Script { public static object Main() { return null; } }" },
{ "className", "Script" },
{ "methodName", "Main" }
},
timeoutSeconds: 3);

Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode);
}

/// <summary>
/// Send an RPC with an optional timeout override — matches what the CLI does.
/// </summary>
private async Task<HttpResponseMessage> SendRpcWithTimeoutAsync(
string command, Dictionary<string, object?>? args, int? timeoutSeconds)
{
var request = new { command, args, timeout = timeoutSeconds };
var json = JsonHelper.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
return await _fixture.HttpClient.PostAsync("/rpc", content);
}

private static async Task<ResponseMessage> ParseResponseAsync(HttpResponseMessage response)
{
var json = await response.Content.ReadAsStringAsync();
return JsonHelper.Deserialize<ResponseMessage>(json)!;
}
}