diff --git a/.claude/skills/unity-editor/SKILL.md b/.claude/skills/unity-editor/SKILL.md index 9d4058f..25c70b2 100644 --- a/.claude/skills/unity-editor/SKILL.md +++ b/.claude/skills/unity-editor/SKILL.md @@ -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 ` 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 diff --git a/UnityCtl.Bridge/BridgeEndpoints.cs b/UnityCtl.Bridge/BridgeEndpoints.cs index 95eb418..1659b75 100644 --- a/UnityCtl.Bridge/BridgeEndpoints.cs +++ b/UnityCtl.Bridge/BridgeEndpoints.cs @@ -427,7 +427,10 @@ private static async Task 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); @@ -1229,6 +1232,13 @@ public class RpcRequest public string? AgentId { get; set; } public required string Command { get; set; } public Dictionary? Args { get; set; } + + /// + /// Optional timeout override in seconds. When set, takes precedence over + /// per-command and default timeouts. Useful for long-running script executions + /// like player builds. + /// + public int? Timeout { get; set; } } internal class CommandConfig diff --git a/UnityCtl.Cli/BridgeClient.cs b/UnityCtl.Cli/BridgeClient.cs index 51e671c..1dbb578 100644 --- a/UnityCtl.Cli/BridgeClient.cs +++ b/UnityCtl.Cli/BridgeClient.cs @@ -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; @@ -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) @@ -118,7 +123,7 @@ public BridgeClient(string baseUrl, string? agentId = null, string? projectRoot } } - public async Task SendCommandAsync(string command, Dictionary? args = null) + public async Task SendCommandAsync(string command, Dictionary? args = null, int? timeoutSeconds = null) { try { @@ -126,13 +131,22 @@ public BridgeClient(string baseUrl, string? agentId = null, string? projectRoot { 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) { diff --git a/UnityCtl.Cli/ScriptCommands.cs b/UnityCtl.Cli/ScriptCommands.cs index 87e02dd..9a53956 100644 --- a/UnityCtl.Cli/ScriptCommands.cs +++ b/UnityCtl.Cli/ScriptCommands.cs @@ -28,6 +28,11 @@ public static Command CreateCommand() { var scriptCommand = new Command("script", "C# script execution operations"); + var timeoutOption = new Option( + 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"); @@ -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) => @@ -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) @@ -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) => @@ -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) diff --git a/UnityCtl.Tests/Integration/RequestTimeoutTests.cs b/UnityCtl.Tests/Integration/RequestTimeoutTests.cs new file mode 100644 index 0000000..0679d18 --- /dev/null +++ b/UnityCtl.Tests/Integration/RequestTimeoutTests.cs @@ -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; + +/// +/// Tests for the request-level timeout override on RpcRequest. +/// +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 + { + { "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 + { + { "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 + { + { "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 + { + { "code", "public class Script { public static object Main() { return null; } }" }, + { "className", "Script" }, + { "methodName", "Main" } + }, + timeoutSeconds: 3); + + Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode); + } + + /// + /// Send an RPC with an optional timeout override — matches what the CLI does. + /// + private async Task SendRpcWithTimeoutAsync( + string command, Dictionary? 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 ParseResponseAsync(HttpResponseMessage response) + { + var json = await response.Content.ReadAsStringAsync(); + return JsonHelper.Deserialize(json)!; + } +}