From bb280cad559404b7cc255d4d33e2481bf1f31a92 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 16:15:59 +0000 Subject: [PATCH 1/6] Add build player command Introduces `unityctl build player` for triggering Unity player builds from the CLI. Supports standard builds (--target, --output, --scenes) and custom build code (--code, --file, stdin). The bridge translates build.player to script.execute with a 600s timeout, requiring zero Unity plugin changes. Also fixes HttpClient timeout (was 100s default) so long-running commands like builds and test runs don't race against the bridge's server-side timeouts. https://claude.ai/code/session_01QEZw9bAURLGDzf8Gfc6ocQ --- UnityCtl.Bridge/BridgeEndpoints.cs | 30 ++ UnityCtl.Cli/BridgeClient.cs | 8 +- UnityCtl.Cli/BuildCommands.cs | 282 ++++++++++++++++++ UnityCtl.Cli/Program.cs | 1 + UnityCtl.Protocol/Constants.cs | 3 + UnityCtl.Tests/Integration/BuildFlowTests.cs | 142 +++++++++ .../Unit/Cli/BuildCodeGeneratorTests.cs | 104 +++++++ 7 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 UnityCtl.Cli/BuildCommands.cs create mode 100644 UnityCtl.Tests/Integration/BuildFlowTests.cs create mode 100644 UnityCtl.Tests/Unit/Cli/BuildCodeGeneratorTests.cs diff --git a/UnityCtl.Bridge/BridgeEndpoints.cs b/UnityCtl.Bridge/BridgeEndpoints.cs index 95eb418..3e583f5 100644 --- a/UnityCtl.Bridge/BridgeEndpoints.cs +++ b/UnityCtl.Bridge/BridgeEndpoints.cs @@ -64,6 +64,10 @@ public static class BridgeEndpoints { TimeoutEnvVar = "UNITYCTL_TIMEOUT_RECORD", TimeoutDefaultSeconds = 600, CompletionEvent = UnityCtlEvents.RecordFinished + }, + [UnityCtlCommands.BuildPlayer] = new CommandConfig + { + TimeoutEnvVar = "UNITYCTL_TIMEOUT_BUILD", TimeoutDefaultSeconds = 600 } }; @@ -438,6 +442,9 @@ private static async Task HandleRpcAsync(BridgeState state, HttpContext if (request.Command == UnityCtlCommands.RecordStart) return await HandleRecordStartAsync(state, requestMessage, request, config!, timeout, context.RequestAborted); + if (request.Command == UnityCtlCommands.BuildPlayer) + return await HandleBuildPlayerAsync(state, requestMessage, timeout, context.RequestAborted); + return await HandleGenericCommandAsync(state, requestMessage, hasConfig ? config : null, timeout, context.RequestAborted); } catch (OperationCanceledException) @@ -789,6 +796,29 @@ private static async Task HandleRecordStartAsync( return JsonResponse(response); } + private static async Task HandleBuildPlayerAsync( + BridgeState state, + RequestMessage requestMessage, + TimeSpan timeout, + CancellationToken cancellationToken) + { + // Rewrite build.player → script.execute so Unity handles it via the existing ScriptExecutor. + // The bridge owns the longer timeout; Unity sees a normal script.execute. + var scriptRequest = new RequestMessage + { + Origin = requestMessage.Origin, + RequestId = requestMessage.RequestId, + AgentId = requestMessage.AgentId, + Command = UnityCtlCommands.ScriptExecute, + Args = requestMessage.Args + }; + + Console.WriteLine($"[Bridge] build.player: forwarding as script.execute (timeout: {timeout.TotalSeconds}s)"); + + var response = await state.SendCommandToUnityAsync(scriptRequest, timeout, cancellationToken); + return JsonResponse(response); + } + private static async Task HandleGenericCommandAsync( BridgeState state, RequestMessage requestMessage, diff --git a/UnityCtl.Cli/BridgeClient.cs b/UnityCtl.Cli/BridgeClient.cs index 51e671c..3d8dca6 100644 --- a/UnityCtl.Cli/BridgeClient.cs +++ b/UnityCtl.Cli/BridgeClient.cs @@ -22,7 +22,13 @@ 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), + // The bridge enforces per-command timeouts server-side. + // The CLI should not race against those with a shorter HTTP timeout. + Timeout = TimeSpan.FromMinutes(15) + }; } public static BridgeClient? TryCreateFromProject(string? projectPath, string? agentId) diff --git a/UnityCtl.Cli/BuildCommands.cs b/UnityCtl.Cli/BuildCommands.cs new file mode 100644 index 0000000..8ad36a9 --- /dev/null +++ b/UnityCtl.Cli/BuildCommands.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Generic; +using System.CommandLine; +using System.CommandLine.Invocation; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Newtonsoft.Json; +using UnityCtl.Protocol; + +namespace UnityCtl.Cli; + +public static class BuildCommands +{ + private static readonly string[] DefaultUsings = + [ + "System", + "System.Linq", + "UnityEngine", + "UnityEditor", + "UnityEditor.Build.Reporting" + ]; + + public static Command CreateCommand() + { + var buildCommand = new Command("build", "Build operations"); + + // build player + var playerCommand = new Command("player", "Build the Unity player (standalone, mobile, WebGL, etc.)"); + + var targetOption = new Option( + aliases: ["--target", "-t"], + description: "Build target (e.g., StandaloneWindows64, StandaloneLinux64, StandaloneOSX, Android, iOS, WebGL)" + ); + + var outputOption = new Option( + aliases: ["--output", "-o"], + description: "Output path for the build" + ); + + var scenesOption = new Option( + aliases: ["--scenes", "-s"], + description: "Scene paths to include (e.g., Assets/Scenes/Main.unity). Defaults to scenes enabled in Build Settings.", + getDefaultValue: () => Array.Empty() + ); + + var codeOption = new Option( + aliases: ["--code", "-c"], + description: "Custom C# build code (replaces standard BuildPipeline.BuildPlayer call)" + ); + + var fileOption = new Option( + aliases: ["--file", "-f"], + description: "Read custom C# build code from a file" + ); + + playerCommand.AddOption(targetOption); + playerCommand.AddOption(outputOption); + playerCommand.AddOption(scenesOption); + playerCommand.AddOption(codeOption); + playerCommand.AddOption(fileOption); + + playerCommand.SetHandler(async (InvocationContext context) => + { + var projectPath = ContextHelper.GetProjectPath(context); + var agentId = ContextHelper.GetAgentId(context); + var json = ContextHelper.GetJson(context); + + var target = context.ParseResult.GetValueForOption(targetOption); + var output = context.ParseResult.GetValueForOption(outputOption); + var scenes = context.ParseResult.GetValueForOption(scenesOption) ?? Array.Empty(); + var code = context.ParseResult.GetValueForOption(codeOption); + var file = context.ParseResult.GetValueForOption(fileOption); + + // Determine build code source + string? buildCode = null; + + if (!string.IsNullOrEmpty(code)) + { + buildCode = code; + } + else if (file != null) + { + if (!file.Exists) + { + Console.Error.WriteLine($"Error: File not found: {file.FullName}"); + context.ExitCode = 1; + return; + } + buildCode = await File.ReadAllTextAsync(file.FullName); + } + else if (Console.IsInputRedirected && string.IsNullOrEmpty(target)) + { + // Read custom code from stdin only when no --target is specified + buildCode = await Console.In.ReadToEndAsync(); + } + + string csharpCode; + + if (!string.IsNullOrWhiteSpace(buildCode)) + { + // Custom build code — wrap if needed (same logic as script eval) + csharpCode = BuildCustomCode(buildCode); + } + else + { + // Standard build — requires --target and --output + if (string.IsNullOrEmpty(target)) + { + Console.Error.WriteLine("Error: --target is required for standard builds."); + Console.Error.WriteLine(); + Console.Error.WriteLine("Example:"); + Console.Error.WriteLine(" unityctl build player --target StandaloneLinux64 --output ./Builds/MyGame"); + Console.Error.WriteLine(" unityctl build player --target WebGL --output ./Builds/WebGL --scenes Assets/Scenes/Main.unity"); + Console.Error.WriteLine(); + Console.Error.WriteLine("For custom builds:"); + Console.Error.WriteLine(" unityctl build player --code \"var report = BuildPipeline.BuildPlayer(...); return report.summary.result.ToString();\""); + Console.Error.WriteLine(" unityctl build player --file ./Editor/MyBuildScript.cs"); + context.ExitCode = 1; + return; + } + + if (string.IsNullOrEmpty(output)) + { + Console.Error.WriteLine("Error: --output is required for standard builds."); + Console.Error.WriteLine(); + Console.Error.WriteLine("Example:"); + Console.Error.WriteLine(" unityctl build player --target StandaloneLinux64 --output ./Builds/MyGame"); + context.ExitCode = 1; + return; + } + + csharpCode = BuildStandardCode(target, output, scenes); + } + + var client = BridgeClient.TryCreateFromProject(projectPath, agentId); + if (client == null) { context.ExitCode = 1; return; } + + var args = new Dictionary + { + { "code", csharpCode }, + { "className", "Script" }, + { "methodName", "Main" } + }; + + var response = await client.SendCommandAsync(UnityCtlCommands.BuildPlayer, args); + if (response == null) { context.ExitCode = 1; return; } + + if (response.Status == ResponseStatus.Error) + { + Console.Error.WriteLine($"Error: {response.Error?.Message}"); + context.ExitCode = 1; + return; + } + + DisplayBuildResult(context, response, json); + }); + + buildCommand.AddCommand(playerCommand); + return buildCommand; + } + + internal static string BuildStandardCode(string target, string output, string[] scenes) + { + var usingBlock = string.Join("\n", DefaultUsings.Select(u => $"using {u};")); + + // If no scenes specified, use scenes from Build Settings + string scenesCode; + if (scenes.Length > 0) + { + var sceneArray = string.Join(", ", scenes.Select(s => $"\"{EscapeCSharpString(s)}\"")); + scenesCode = $"var scenes = new[] {{ {sceneArray} }};"; + } + else + { + scenesCode = "var scenes = EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray();"; + } + + return $@"{usingBlock} + +public class Script +{{ + public static object Main() + {{ + {scenesCode} + + var options = new BuildPlayerOptions + {{ + scenes = scenes, + locationPathName = ""{EscapeCSharpString(output)}"", + target = BuildTarget.{target}, + options = BuildOptions.None + }}; + + var report = BuildPipeline.BuildPlayer(options); + var summary = report.summary; + + return new + {{ + result = summary.result.ToString(), + totalErrors = summary.totalErrors, + totalWarnings = summary.totalWarnings, + totalSize = summary.totalSize, + totalTime = summary.totalTime.TotalSeconds, + outputPath = summary.outputPath + }}; + }} +}} +"; + } + + internal static string BuildCustomCode(string code) + { + // If the code already contains a class definition, use it as-is + if (code.Contains("class ")) + { + return code; + } + + // Otherwise, wrap it like script eval does + var usingBlock = string.Join("\n", DefaultUsings.Select(u => $"using {u};")); + var isBodyMode = code.TrimEnd().EndsWith(';'); + var body = isBodyMode ? code : $"return {code};"; + + return $@"{usingBlock} + +public class Script +{{ + public static object Main() + {{ + {body} + }} +}} +"; + } + + private static string EscapeCSharpString(string value) + { + return value.Replace("\\", "\\\\").Replace("\"", "\\\""); + } + + private static void DisplayBuildResult(InvocationContext context, ResponseMessage response, bool json) + { + // The result is a ScriptExecuteResult wrapping the build output + var result = JsonConvert.DeserializeObject( + JsonConvert.SerializeObject(response.Result, JsonHelper.Settings), + JsonHelper.Settings + ); + + if (result != null && !result.Success) + { + context.ExitCode = 1; + } + + if (json) + { + Console.WriteLine(JsonHelper.Serialize(response.Result)); + } + else + { + if (result == null) return; + + if (result.Success) + { + Console.WriteLine($"Build result: {result.Result ?? "(void)"}"); + } + else + { + Console.Error.WriteLine($"Build failed: {result.Error}"); + if (result.Diagnostics != null && result.Diagnostics.Length > 0) + { + Console.Error.WriteLine(); + Console.Error.WriteLine("Diagnostics:"); + foreach (var diagnostic in result.Diagnostics) + { + Console.Error.WriteLine($" {diagnostic}"); + } + } + } + } + } +} diff --git a/UnityCtl.Cli/Program.cs b/UnityCtl.Cli/Program.cs index 3ff9d0a..7b31dd9 100644 --- a/UnityCtl.Cli/Program.cs +++ b/UnityCtl.Cli/Program.cs @@ -50,5 +50,6 @@ rootCommand.AddCommand(ScreenshotCommands.CreateCommand()); rootCommand.AddCommand(RecordCommands.CreateCommand()); rootCommand.AddCommand(ScriptCommands.CreateCommand()); +rootCommand.AddCommand(BuildCommands.CreateCommand()); return await rootCommand.InvokeAsync(args); diff --git a/UnityCtl.Protocol/Constants.cs b/UnityCtl.Protocol/Constants.cs index 080b4ef..6232685 100644 --- a/UnityCtl.Protocol/Constants.cs +++ b/UnityCtl.Protocol/Constants.cs @@ -33,6 +33,9 @@ public static class UnityCtlCommands public const string RecordStart = "record.start"; public const string RecordStop = "record.stop"; public const string RecordStatus = "record.status"; + + // Build + public const string BuildPlayer = "build.player"; } public static class UnityCtlEvents diff --git a/UnityCtl.Tests/Integration/BuildFlowTests.cs b/UnityCtl.Tests/Integration/BuildFlowTests.cs new file mode 100644 index 0000000..08c8f24 --- /dev/null +++ b/UnityCtl.Tests/Integration/BuildFlowTests.cs @@ -0,0 +1,142 @@ +using System.Net; +using Newtonsoft.Json.Linq; +using UnityCtl.Protocol; +using UnityCtl.Tests.Fakes; +using UnityCtl.Tests.Helpers; +using Xunit; + +namespace UnityCtl.Tests.Integration; + +public class BuildFlowTests : IAsyncLifetime +{ + private readonly BridgeTestFixture _fixture = new(); + + public Task InitializeAsync() + { + Environment.SetEnvironmentVariable("UNITYCTL_TIMEOUT_BUILD", "5"); + return _fixture.InitializeAsync(); + } + + public async Task DisposeAsync() + { + Environment.SetEnvironmentVariable("UNITYCTL_TIMEOUT_BUILD", null); + await _fixture.DisposeAsync(); + } + + [Fact] + public async Task BuildPlayer_ForwardsAsScriptExecute() + { + // The bridge should forward build.player as script.execute to Unity + _fixture.FakeUnity.OnCommand(UnityCtlCommands.ScriptExecute, request => + { + // Verify the args contain the build code + var args = request.Args; + Assert.NotNull(args); + Assert.True(args!.ContainsKey("code")); + + return new ScriptExecuteResult + { + Success = true, + Result = "{ \"result\": \"Succeeded\" }" + }; + }); + + var response = await _fixture.SendRpcAsync(UnityCtlCommands.BuildPlayer, new Dictionary + { + { "code", "public class Script { public static object Main() { return \"ok\"; } }" }, + { "className", "Script" }, + { "methodName", "Main" } + }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var responseMessage = await ParseResponseAsync(response); + Assert.Equal(ResponseStatus.Ok, responseMessage.Status); + } + + [Fact] + public async Task BuildPlayer_UnityReturnsError_PropagatesError() + { + _fixture.FakeUnity.OnCommandError( + UnityCtlCommands.ScriptExecute, + "command_failed", + "Build failed: missing scenes"); + + var response = await _fixture.SendRpcAsync(UnityCtlCommands.BuildPlayer, new Dictionary + { + { "code", "public class Script { public static object Main() { return null; } }" }, + { "className", "Script" }, + { "methodName", "Main" } + }); + + 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 BuildPlayer_TimesOutOnSlowBuild() + { + // Build takes longer than the 5-second test timeout + _fixture.FakeUnity.OnCommandWithDelay( + UnityCtlCommands.ScriptExecute, + TimeSpan.FromSeconds(10), + _ => new ScriptExecuteResult { Success = true, Result = "done" }); + + var response = await _fixture.SendRpcAsync(UnityCtlCommands.BuildPlayer, new Dictionary + { + { "code", "public class Script { public static object Main() { return null; } }" }, + { "className", "Script" }, + { "methodName", "Main" } + }); + + Assert.Equal(HttpStatusCode.GatewayTimeout, response.StatusCode); + } + + [Fact] + public async Task BuildPlayer_CommandReceivedByUnityAsScriptExecute() + { + // Verify Unity sees script.execute, not build.player + _fixture.FakeUnity.OnCommand(UnityCtlCommands.ScriptExecute, _ => + new ScriptExecuteResult { Success = true, Result = "ok" }); + + await _fixture.SendRpcAsync(UnityCtlCommands.BuildPlayer, new Dictionary + { + { "code", "test code" }, + { "className", "Script" }, + { "methodName", "Main" } + }); + + var received = await _fixture.FakeUnity.WaitForRequestAsync(UnityCtlCommands.ScriptExecute); + Assert.Equal(UnityCtlCommands.ScriptExecute, received.Command); + } + + [Fact] + public async Task BuildPlayer_PreservesCodeArgs() + { + var buildCode = "public class Script { public static object Main() { return BuildPipeline.BuildPlayer(new string[0], \"out\", BuildTarget.WebGL, BuildOptions.None); } }"; + + _fixture.FakeUnity.OnCommand(UnityCtlCommands.ScriptExecute, _ => + new ScriptExecuteResult { Success = true, Result = "ok" }); + + await _fixture.SendRpcAsync(UnityCtlCommands.BuildPlayer, new Dictionary + { + { "code", buildCode }, + { "className", "Script" }, + { "methodName", "Main" } + }); + + var received = await _fixture.FakeUnity.WaitForRequestAsync(UnityCtlCommands.ScriptExecute); + var codeArg = (received.Args?["code"] as JValue)?.Value() + ?? received.Args?["code"]?.ToString(); + Assert.Equal(buildCode, codeArg); + } + + private static async Task ParseResponseAsync(HttpResponseMessage response) + { + var json = await response.Content.ReadAsStringAsync(); + return JsonHelper.Deserialize(json)!; + } +} diff --git a/UnityCtl.Tests/Unit/Cli/BuildCodeGeneratorTests.cs b/UnityCtl.Tests/Unit/Cli/BuildCodeGeneratorTests.cs new file mode 100644 index 0000000..e98d5fc --- /dev/null +++ b/UnityCtl.Tests/Unit/Cli/BuildCodeGeneratorTests.cs @@ -0,0 +1,104 @@ +using UnityCtl.Cli; +using Xunit; + +namespace UnityCtl.Tests.Unit.Cli; + +public class BuildCodeGeneratorTests +{ + [Fact] + public void StandardBuild_ContainsBuildTarget() + { + var code = BuildCommands.BuildStandardCode("StandaloneLinux64", "./Builds/MyGame", []); + + Assert.Contains("BuildTarget.StandaloneLinux64", code); + } + + [Fact] + public void StandardBuild_ContainsOutputPath() + { + var code = BuildCommands.BuildStandardCode("StandaloneLinux64", "./Builds/MyGame", []); + + Assert.Contains("./Builds/MyGame", code); + } + + [Fact] + public void StandardBuild_NoScenes_UsesBuildSettings() + { + var code = BuildCommands.BuildStandardCode("StandaloneLinux64", "./Builds/MyGame", []); + + Assert.Contains("EditorBuildSettings.scenes", code); + } + + [Fact] + public void StandardBuild_WithScenes_UsesExplicitScenes() + { + var code = BuildCommands.BuildStandardCode("StandaloneLinux64", "./Builds/MyGame", + ["Assets/Scenes/Main.unity", "Assets/Scenes/Menu.unity"]); + + Assert.Contains("\"Assets/Scenes/Main.unity\"", code); + Assert.Contains("\"Assets/Scenes/Menu.unity\"", code); + Assert.DoesNotContain("EditorBuildSettings.scenes", code); + } + + [Fact] + public void StandardBuild_HasRequiredUsings() + { + var code = BuildCommands.BuildStandardCode("StandaloneLinux64", "./Builds/MyGame", []); + + Assert.Contains("using UnityEditor;", code); + Assert.Contains("using UnityEditor.Build.Reporting;", code); + } + + [Fact] + public void StandardBuild_ReturnsBuildReport() + { + var code = BuildCommands.BuildStandardCode("StandaloneLinux64", "./Builds/MyGame", []); + + Assert.Contains("BuildPipeline.BuildPlayer(options)", code); + Assert.Contains("summary.result.ToString()", code); + Assert.Contains("summary.totalErrors", code); + } + + [Fact] + public void StandardBuild_EscapesOutputPath() + { + var code = BuildCommands.BuildStandardCode("StandaloneWindows64", "C:\\Builds\\My Game", []); + + Assert.Contains("C:\\\\Builds\\\\My Game", code); + } + + [Fact] + public void CustomBuild_WithClassDefinition_UsedAsIs() + { + var custom = "using UnityEditor;\npublic class MyBuild { public static object Main() { return null; } }"; + var code = BuildCommands.BuildCustomCode(custom); + + Assert.Equal(custom, code); + } + + [Fact] + public void CustomBuild_Expression_WrappedWithReturn() + { + var code = BuildCommands.BuildCustomCode("BuildPipeline.BuildPlayer(new string[0], \"out\", BuildTarget.WebGL, BuildOptions.None)"); + + Assert.Contains("return BuildPipeline.BuildPlayer", code); + Assert.Contains("public class Script", code); + } + + [Fact] + public void CustomBuild_BodyStatements_UsedAsBody() + { + var code = BuildCommands.BuildCustomCode("var report = BuildPipeline.BuildPlayer(new string[0], \"out\", BuildTarget.WebGL, BuildOptions.None); return report.summary.result.ToString();"); + + Assert.Contains("var report = BuildPipeline.BuildPlayer", code); + Assert.DoesNotContain("return var report", code); + } + + [Fact] + public void CustomBuild_HasBuildUsings() + { + var code = BuildCommands.BuildCustomCode("BuildPipeline.BuildPlayer(new string[0], \"out\", BuildTarget.WebGL, BuildOptions.None)"); + + Assert.Contains("using UnityEditor.Build.Reporting;", code); + } +} From de7e4cfec62653bca5255fc686a1c5e70d87b325 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 17:39:33 +0000 Subject: [PATCH 2/6] Replace build.player command with request-level timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of a dedicated bridge command that rewrites build.player to script.execute, add an optional timeout field to RpcRequest. The CLI build command now sends script.execute with timeout=600. This is simpler and more general — any caller can override the timeout for long-running scripts without needing bridge-side plumbing per use case. https://claude.ai/code/session_01QEZw9bAURLGDzf8Gfc6ocQ --- UnityCtl.Bridge/BridgeEndpoints.cs | 42 ++---- UnityCtl.Cli/BridgeClient.cs | 5 +- UnityCtl.Cli/BuildCommands.cs | 2 +- UnityCtl.Protocol/Constants.cs | 3 - UnityCtl.Tests/Integration/BuildFlowTests.cs | 150 ++++++++++--------- 5 files changed, 91 insertions(+), 111 deletions(-) diff --git a/UnityCtl.Bridge/BridgeEndpoints.cs b/UnityCtl.Bridge/BridgeEndpoints.cs index 3e583f5..1659b75 100644 --- a/UnityCtl.Bridge/BridgeEndpoints.cs +++ b/UnityCtl.Bridge/BridgeEndpoints.cs @@ -64,10 +64,6 @@ public static class BridgeEndpoints { TimeoutEnvVar = "UNITYCTL_TIMEOUT_RECORD", TimeoutDefaultSeconds = 600, CompletionEvent = UnityCtlEvents.RecordFinished - }, - [UnityCtlCommands.BuildPlayer] = new CommandConfig - { - TimeoutEnvVar = "UNITYCTL_TIMEOUT_BUILD", TimeoutDefaultSeconds = 600 } }; @@ -431,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); @@ -442,9 +441,6 @@ private static async Task HandleRpcAsync(BridgeState state, HttpContext if (request.Command == UnityCtlCommands.RecordStart) return await HandleRecordStartAsync(state, requestMessage, request, config!, timeout, context.RequestAborted); - if (request.Command == UnityCtlCommands.BuildPlayer) - return await HandleBuildPlayerAsync(state, requestMessage, timeout, context.RequestAborted); - return await HandleGenericCommandAsync(state, requestMessage, hasConfig ? config : null, timeout, context.RequestAborted); } catch (OperationCanceledException) @@ -796,29 +792,6 @@ private static async Task HandleRecordStartAsync( return JsonResponse(response); } - private static async Task HandleBuildPlayerAsync( - BridgeState state, - RequestMessage requestMessage, - TimeSpan timeout, - CancellationToken cancellationToken) - { - // Rewrite build.player → script.execute so Unity handles it via the existing ScriptExecutor. - // The bridge owns the longer timeout; Unity sees a normal script.execute. - var scriptRequest = new RequestMessage - { - Origin = requestMessage.Origin, - RequestId = requestMessage.RequestId, - AgentId = requestMessage.AgentId, - Command = UnityCtlCommands.ScriptExecute, - Args = requestMessage.Args - }; - - Console.WriteLine($"[Bridge] build.player: forwarding as script.execute (timeout: {timeout.TotalSeconds}s)"); - - var response = await state.SendCommandToUnityAsync(scriptRequest, timeout, cancellationToken); - return JsonResponse(response); - } - private static async Task HandleGenericCommandAsync( BridgeState state, RequestMessage requestMessage, @@ -1259,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 3d8dca6..a6ef27b 100644 --- a/UnityCtl.Cli/BridgeClient.cs +++ b/UnityCtl.Cli/BridgeClient.cs @@ -124,7 +124,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 { @@ -132,7 +132,8 @@ public BridgeClient(string baseUrl, string? agentId = null, string? projectRoot { agentId = _agentId, command = command, - args = args + args = args, + timeout = timeoutSeconds }; var json = JsonHelper.Serialize(request); diff --git a/UnityCtl.Cli/BuildCommands.cs b/UnityCtl.Cli/BuildCommands.cs index 8ad36a9..a6e7546 100644 --- a/UnityCtl.Cli/BuildCommands.cs +++ b/UnityCtl.Cli/BuildCommands.cs @@ -143,7 +143,7 @@ public static Command CreateCommand() { "methodName", "Main" } }; - var response = await client.SendCommandAsync(UnityCtlCommands.BuildPlayer, args); + var response = await client.SendCommandAsync(UnityCtlCommands.ScriptExecute, args, timeoutSeconds: 600); if (response == null) { context.ExitCode = 1; return; } if (response.Status == ResponseStatus.Error) diff --git a/UnityCtl.Protocol/Constants.cs b/UnityCtl.Protocol/Constants.cs index 6232685..080b4ef 100644 --- a/UnityCtl.Protocol/Constants.cs +++ b/UnityCtl.Protocol/Constants.cs @@ -33,9 +33,6 @@ public static class UnityCtlCommands public const string RecordStart = "record.start"; public const string RecordStop = "record.stop"; public const string RecordStatus = "record.status"; - - // Build - public const string BuildPlayer = "build.player"; } public static class UnityCtlEvents diff --git a/UnityCtl.Tests/Integration/BuildFlowTests.cs b/UnityCtl.Tests/Integration/BuildFlowTests.cs index 08c8f24..e0f8cfd 100644 --- a/UnityCtl.Tests/Integration/BuildFlowTests.cs +++ b/UnityCtl.Tests/Integration/BuildFlowTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Text; using Newtonsoft.Json.Linq; using UnityCtl.Protocol; using UnityCtl.Tests.Fakes; @@ -7,46 +8,46 @@ namespace UnityCtl.Tests.Integration; +/// +/// Tests for the request-level timeout override, which enables long-running +/// script executions like player builds without a dedicated bridge command. +/// public class BuildFlowTests : IAsyncLifetime { private readonly BridgeTestFixture _fixture = new(); public Task InitializeAsync() { - Environment.SetEnvironmentVariable("UNITYCTL_TIMEOUT_BUILD", "5"); + // 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_BUILD", null); + Environment.SetEnvironmentVariable("UNITYCTL_TIMEOUT_DEFAULT", null); await _fixture.DisposeAsync(); } [Fact] - public async Task BuildPlayer_ForwardsAsScriptExecute() + public async Task ScriptExecute_WithTimeoutOverride_UsesRequestTimeout() { - // The bridge should forward build.player as script.execute to Unity - _fixture.FakeUnity.OnCommand(UnityCtlCommands.ScriptExecute, request => - { - // Verify the args contain the build code - var args = request.Args; - Assert.NotNull(args); - Assert.True(args!.ContainsKey("code")); - - return new ScriptExecuteResult + // 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 { - Success = true, - Result = "{ \"result\": \"Succeeded\" }" - }; - }); - - var response = await _fixture.SendRpcAsync(UnityCtlCommands.BuildPlayer, new Dictionary - { - { "code", "public class Script { public static object Main() { return \"ok\"; } }" }, - { "className", "Script" }, - { "methodName", "Main" } - }); + { "code", "public class Script { public static object Main() { return \"ok\"; } }" }, + { "className", "Script" }, + { "methodName", "Main" } + }, + timeoutSeconds: 10); Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -55,19 +56,44 @@ public async Task BuildPlayer_ForwardsAsScriptExecute() } [Fact] - public async Task BuildPlayer_UnityReturnsError_PropagatesError() + 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 _fixture.SendRpcAsync(UnityCtlCommands.BuildPlayer, new Dictionary - { - { "code", "public class Script { public static object Main() { return null; } }" }, - { "className", "Script" }, - { "methodName", "Main" } - }); + 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); @@ -77,61 +103,37 @@ public async Task BuildPlayer_UnityReturnsError_PropagatesError() } [Fact] - public async Task BuildPlayer_TimesOutOnSlowBuild() + public async Task ScriptExecute_TimeoutOverride_StillTimesOut() { - // Build takes longer than the 5-second test timeout + // 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 _fixture.SendRpcAsync(UnityCtlCommands.BuildPlayer, new Dictionary - { - { "code", "public class Script { public static object Main() { return null; } }" }, - { "className", "Script" }, - { "methodName", "Main" } - }); + 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); } - [Fact] - public async Task BuildPlayer_CommandReceivedByUnityAsScriptExecute() - { - // Verify Unity sees script.execute, not build.player - _fixture.FakeUnity.OnCommand(UnityCtlCommands.ScriptExecute, _ => - new ScriptExecuteResult { Success = true, Result = "ok" }); - - await _fixture.SendRpcAsync(UnityCtlCommands.BuildPlayer, new Dictionary - { - { "code", "test code" }, - { "className", "Script" }, - { "methodName", "Main" } - }); - - var received = await _fixture.FakeUnity.WaitForRequestAsync(UnityCtlCommands.ScriptExecute); - Assert.Equal(UnityCtlCommands.ScriptExecute, received.Command); - } - - [Fact] - public async Task BuildPlayer_PreservesCodeArgs() + /// + /// Send an RPC with an optional timeout override — matches what the CLI does. + /// + private async Task SendRpcWithTimeoutAsync( + string command, Dictionary? args, int? timeoutSeconds) { - var buildCode = "public class Script { public static object Main() { return BuildPipeline.BuildPlayer(new string[0], \"out\", BuildTarget.WebGL, BuildOptions.None); } }"; - - _fixture.FakeUnity.OnCommand(UnityCtlCommands.ScriptExecute, _ => - new ScriptExecuteResult { Success = true, Result = "ok" }); - - await _fixture.SendRpcAsync(UnityCtlCommands.BuildPlayer, new Dictionary - { - { "code", buildCode }, - { "className", "Script" }, - { "methodName", "Main" } - }); - - var received = await _fixture.FakeUnity.WaitForRequestAsync(UnityCtlCommands.ScriptExecute); - var codeArg = (received.Args?["code"] as JValue)?.Value() - ?? received.Args?["code"]?.ToString(); - Assert.Equal(buildCode, codeArg); + 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) From 0faac0a670df24c7fa7eed1d57d7e324600470dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 17:59:47 +0000 Subject: [PATCH 3/6] Remove build command, keep request-level timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build workflow is better left to users via script eval + timeout. Removes BuildCommands.cs and its tests. The useful part — the timeout field on RpcRequest — stays. https://claude.ai/code/session_01QEZw9bAURLGDzf8Gfc6ocQ --- UnityCtl.Cli/BuildCommands.cs | 282 ------------------ UnityCtl.Cli/Program.cs | 1 - ...ildFlowTests.cs => RequestTimeoutTests.cs} | 6 +- .../Unit/Cli/BuildCodeGeneratorTests.cs | 104 ------- 4 files changed, 2 insertions(+), 391 deletions(-) delete mode 100644 UnityCtl.Cli/BuildCommands.cs rename UnityCtl.Tests/Integration/{BuildFlowTests.cs => RequestTimeoutTests.cs} (95%) delete mode 100644 UnityCtl.Tests/Unit/Cli/BuildCodeGeneratorTests.cs diff --git a/UnityCtl.Cli/BuildCommands.cs b/UnityCtl.Cli/BuildCommands.cs deleted file mode 100644 index a6e7546..0000000 --- a/UnityCtl.Cli/BuildCommands.cs +++ /dev/null @@ -1,282 +0,0 @@ -using System; -using System.Collections.Generic; -using System.CommandLine; -using System.CommandLine.Invocation; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using Newtonsoft.Json; -using UnityCtl.Protocol; - -namespace UnityCtl.Cli; - -public static class BuildCommands -{ - private static readonly string[] DefaultUsings = - [ - "System", - "System.Linq", - "UnityEngine", - "UnityEditor", - "UnityEditor.Build.Reporting" - ]; - - public static Command CreateCommand() - { - var buildCommand = new Command("build", "Build operations"); - - // build player - var playerCommand = new Command("player", "Build the Unity player (standalone, mobile, WebGL, etc.)"); - - var targetOption = new Option( - aliases: ["--target", "-t"], - description: "Build target (e.g., StandaloneWindows64, StandaloneLinux64, StandaloneOSX, Android, iOS, WebGL)" - ); - - var outputOption = new Option( - aliases: ["--output", "-o"], - description: "Output path for the build" - ); - - var scenesOption = new Option( - aliases: ["--scenes", "-s"], - description: "Scene paths to include (e.g., Assets/Scenes/Main.unity). Defaults to scenes enabled in Build Settings.", - getDefaultValue: () => Array.Empty() - ); - - var codeOption = new Option( - aliases: ["--code", "-c"], - description: "Custom C# build code (replaces standard BuildPipeline.BuildPlayer call)" - ); - - var fileOption = new Option( - aliases: ["--file", "-f"], - description: "Read custom C# build code from a file" - ); - - playerCommand.AddOption(targetOption); - playerCommand.AddOption(outputOption); - playerCommand.AddOption(scenesOption); - playerCommand.AddOption(codeOption); - playerCommand.AddOption(fileOption); - - playerCommand.SetHandler(async (InvocationContext context) => - { - var projectPath = ContextHelper.GetProjectPath(context); - var agentId = ContextHelper.GetAgentId(context); - var json = ContextHelper.GetJson(context); - - var target = context.ParseResult.GetValueForOption(targetOption); - var output = context.ParseResult.GetValueForOption(outputOption); - var scenes = context.ParseResult.GetValueForOption(scenesOption) ?? Array.Empty(); - var code = context.ParseResult.GetValueForOption(codeOption); - var file = context.ParseResult.GetValueForOption(fileOption); - - // Determine build code source - string? buildCode = null; - - if (!string.IsNullOrEmpty(code)) - { - buildCode = code; - } - else if (file != null) - { - if (!file.Exists) - { - Console.Error.WriteLine($"Error: File not found: {file.FullName}"); - context.ExitCode = 1; - return; - } - buildCode = await File.ReadAllTextAsync(file.FullName); - } - else if (Console.IsInputRedirected && string.IsNullOrEmpty(target)) - { - // Read custom code from stdin only when no --target is specified - buildCode = await Console.In.ReadToEndAsync(); - } - - string csharpCode; - - if (!string.IsNullOrWhiteSpace(buildCode)) - { - // Custom build code — wrap if needed (same logic as script eval) - csharpCode = BuildCustomCode(buildCode); - } - else - { - // Standard build — requires --target and --output - if (string.IsNullOrEmpty(target)) - { - Console.Error.WriteLine("Error: --target is required for standard builds."); - Console.Error.WriteLine(); - Console.Error.WriteLine("Example:"); - Console.Error.WriteLine(" unityctl build player --target StandaloneLinux64 --output ./Builds/MyGame"); - Console.Error.WriteLine(" unityctl build player --target WebGL --output ./Builds/WebGL --scenes Assets/Scenes/Main.unity"); - Console.Error.WriteLine(); - Console.Error.WriteLine("For custom builds:"); - Console.Error.WriteLine(" unityctl build player --code \"var report = BuildPipeline.BuildPlayer(...); return report.summary.result.ToString();\""); - Console.Error.WriteLine(" unityctl build player --file ./Editor/MyBuildScript.cs"); - context.ExitCode = 1; - return; - } - - if (string.IsNullOrEmpty(output)) - { - Console.Error.WriteLine("Error: --output is required for standard builds."); - Console.Error.WriteLine(); - Console.Error.WriteLine("Example:"); - Console.Error.WriteLine(" unityctl build player --target StandaloneLinux64 --output ./Builds/MyGame"); - context.ExitCode = 1; - return; - } - - csharpCode = BuildStandardCode(target, output, scenes); - } - - var client = BridgeClient.TryCreateFromProject(projectPath, agentId); - if (client == null) { context.ExitCode = 1; return; } - - var args = new Dictionary - { - { "code", csharpCode }, - { "className", "Script" }, - { "methodName", "Main" } - }; - - var response = await client.SendCommandAsync(UnityCtlCommands.ScriptExecute, args, timeoutSeconds: 600); - if (response == null) { context.ExitCode = 1; return; } - - if (response.Status == ResponseStatus.Error) - { - Console.Error.WriteLine($"Error: {response.Error?.Message}"); - context.ExitCode = 1; - return; - } - - DisplayBuildResult(context, response, json); - }); - - buildCommand.AddCommand(playerCommand); - return buildCommand; - } - - internal static string BuildStandardCode(string target, string output, string[] scenes) - { - var usingBlock = string.Join("\n", DefaultUsings.Select(u => $"using {u};")); - - // If no scenes specified, use scenes from Build Settings - string scenesCode; - if (scenes.Length > 0) - { - var sceneArray = string.Join(", ", scenes.Select(s => $"\"{EscapeCSharpString(s)}\"")); - scenesCode = $"var scenes = new[] {{ {sceneArray} }};"; - } - else - { - scenesCode = "var scenes = EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray();"; - } - - return $@"{usingBlock} - -public class Script -{{ - public static object Main() - {{ - {scenesCode} - - var options = new BuildPlayerOptions - {{ - scenes = scenes, - locationPathName = ""{EscapeCSharpString(output)}"", - target = BuildTarget.{target}, - options = BuildOptions.None - }}; - - var report = BuildPipeline.BuildPlayer(options); - var summary = report.summary; - - return new - {{ - result = summary.result.ToString(), - totalErrors = summary.totalErrors, - totalWarnings = summary.totalWarnings, - totalSize = summary.totalSize, - totalTime = summary.totalTime.TotalSeconds, - outputPath = summary.outputPath - }}; - }} -}} -"; - } - - internal static string BuildCustomCode(string code) - { - // If the code already contains a class definition, use it as-is - if (code.Contains("class ")) - { - return code; - } - - // Otherwise, wrap it like script eval does - var usingBlock = string.Join("\n", DefaultUsings.Select(u => $"using {u};")); - var isBodyMode = code.TrimEnd().EndsWith(';'); - var body = isBodyMode ? code : $"return {code};"; - - return $@"{usingBlock} - -public class Script -{{ - public static object Main() - {{ - {body} - }} -}} -"; - } - - private static string EscapeCSharpString(string value) - { - return value.Replace("\\", "\\\\").Replace("\"", "\\\""); - } - - private static void DisplayBuildResult(InvocationContext context, ResponseMessage response, bool json) - { - // The result is a ScriptExecuteResult wrapping the build output - var result = JsonConvert.DeserializeObject( - JsonConvert.SerializeObject(response.Result, JsonHelper.Settings), - JsonHelper.Settings - ); - - if (result != null && !result.Success) - { - context.ExitCode = 1; - } - - if (json) - { - Console.WriteLine(JsonHelper.Serialize(response.Result)); - } - else - { - if (result == null) return; - - if (result.Success) - { - Console.WriteLine($"Build result: {result.Result ?? "(void)"}"); - } - else - { - Console.Error.WriteLine($"Build failed: {result.Error}"); - if (result.Diagnostics != null && result.Diagnostics.Length > 0) - { - Console.Error.WriteLine(); - Console.Error.WriteLine("Diagnostics:"); - foreach (var diagnostic in result.Diagnostics) - { - Console.Error.WriteLine($" {diagnostic}"); - } - } - } - } - } -} diff --git a/UnityCtl.Cli/Program.cs b/UnityCtl.Cli/Program.cs index 7b31dd9..3ff9d0a 100644 --- a/UnityCtl.Cli/Program.cs +++ b/UnityCtl.Cli/Program.cs @@ -50,6 +50,5 @@ rootCommand.AddCommand(ScreenshotCommands.CreateCommand()); rootCommand.AddCommand(RecordCommands.CreateCommand()); rootCommand.AddCommand(ScriptCommands.CreateCommand()); -rootCommand.AddCommand(BuildCommands.CreateCommand()); return await rootCommand.InvokeAsync(args); diff --git a/UnityCtl.Tests/Integration/BuildFlowTests.cs b/UnityCtl.Tests/Integration/RequestTimeoutTests.cs similarity index 95% rename from UnityCtl.Tests/Integration/BuildFlowTests.cs rename to UnityCtl.Tests/Integration/RequestTimeoutTests.cs index e0f8cfd..0679d18 100644 --- a/UnityCtl.Tests/Integration/BuildFlowTests.cs +++ b/UnityCtl.Tests/Integration/RequestTimeoutTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Text; -using Newtonsoft.Json.Linq; using UnityCtl.Protocol; using UnityCtl.Tests.Fakes; using UnityCtl.Tests.Helpers; @@ -9,10 +8,9 @@ namespace UnityCtl.Tests.Integration; /// -/// Tests for the request-level timeout override, which enables long-running -/// script executions like player builds without a dedicated bridge command. +/// Tests for the request-level timeout override on RpcRequest. /// -public class BuildFlowTests : IAsyncLifetime +public class RequestTimeoutTests : IAsyncLifetime { private readonly BridgeTestFixture _fixture = new(); diff --git a/UnityCtl.Tests/Unit/Cli/BuildCodeGeneratorTests.cs b/UnityCtl.Tests/Unit/Cli/BuildCodeGeneratorTests.cs deleted file mode 100644 index e98d5fc..0000000 --- a/UnityCtl.Tests/Unit/Cli/BuildCodeGeneratorTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -using UnityCtl.Cli; -using Xunit; - -namespace UnityCtl.Tests.Unit.Cli; - -public class BuildCodeGeneratorTests -{ - [Fact] - public void StandardBuild_ContainsBuildTarget() - { - var code = BuildCommands.BuildStandardCode("StandaloneLinux64", "./Builds/MyGame", []); - - Assert.Contains("BuildTarget.StandaloneLinux64", code); - } - - [Fact] - public void StandardBuild_ContainsOutputPath() - { - var code = BuildCommands.BuildStandardCode("StandaloneLinux64", "./Builds/MyGame", []); - - Assert.Contains("./Builds/MyGame", code); - } - - [Fact] - public void StandardBuild_NoScenes_UsesBuildSettings() - { - var code = BuildCommands.BuildStandardCode("StandaloneLinux64", "./Builds/MyGame", []); - - Assert.Contains("EditorBuildSettings.scenes", code); - } - - [Fact] - public void StandardBuild_WithScenes_UsesExplicitScenes() - { - var code = BuildCommands.BuildStandardCode("StandaloneLinux64", "./Builds/MyGame", - ["Assets/Scenes/Main.unity", "Assets/Scenes/Menu.unity"]); - - Assert.Contains("\"Assets/Scenes/Main.unity\"", code); - Assert.Contains("\"Assets/Scenes/Menu.unity\"", code); - Assert.DoesNotContain("EditorBuildSettings.scenes", code); - } - - [Fact] - public void StandardBuild_HasRequiredUsings() - { - var code = BuildCommands.BuildStandardCode("StandaloneLinux64", "./Builds/MyGame", []); - - Assert.Contains("using UnityEditor;", code); - Assert.Contains("using UnityEditor.Build.Reporting;", code); - } - - [Fact] - public void StandardBuild_ReturnsBuildReport() - { - var code = BuildCommands.BuildStandardCode("StandaloneLinux64", "./Builds/MyGame", []); - - Assert.Contains("BuildPipeline.BuildPlayer(options)", code); - Assert.Contains("summary.result.ToString()", code); - Assert.Contains("summary.totalErrors", code); - } - - [Fact] - public void StandardBuild_EscapesOutputPath() - { - var code = BuildCommands.BuildStandardCode("StandaloneWindows64", "C:\\Builds\\My Game", []); - - Assert.Contains("C:\\\\Builds\\\\My Game", code); - } - - [Fact] - public void CustomBuild_WithClassDefinition_UsedAsIs() - { - var custom = "using UnityEditor;\npublic class MyBuild { public static object Main() { return null; } }"; - var code = BuildCommands.BuildCustomCode(custom); - - Assert.Equal(custom, code); - } - - [Fact] - public void CustomBuild_Expression_WrappedWithReturn() - { - var code = BuildCommands.BuildCustomCode("BuildPipeline.BuildPlayer(new string[0], \"out\", BuildTarget.WebGL, BuildOptions.None)"); - - Assert.Contains("return BuildPipeline.BuildPlayer", code); - Assert.Contains("public class Script", code); - } - - [Fact] - public void CustomBuild_BodyStatements_UsedAsBody() - { - var code = BuildCommands.BuildCustomCode("var report = BuildPipeline.BuildPlayer(new string[0], \"out\", BuildTarget.WebGL, BuildOptions.None); return report.summary.result.ToString();"); - - Assert.Contains("var report = BuildPipeline.BuildPlayer", code); - Assert.DoesNotContain("return var report", code); - } - - [Fact] - public void CustomBuild_HasBuildUsings() - { - var code = BuildCommands.BuildCustomCode("BuildPipeline.BuildPlayer(new string[0], \"out\", BuildTarget.WebGL, BuildOptions.None)"); - - Assert.Contains("using UnityEditor.Build.Reporting;", code); - } -} From d03517ae2b0bbfff7e4372c1f5e15afadf55b0cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 18:07:18 +0000 Subject: [PATCH 4/6] Use per-request HTTP timeout instead of global 15min override Reverts HttpClient.Timeout to its default (100s). When a caller passes timeoutSeconds, the CancellationTokenSource for that request is set to timeout + 30s buffer, so the bridge always gets to respond with a proper 504 before the HTTP client gives up. Normal commands keep the default behavior. https://claude.ai/code/session_01QEZw9bAURLGDzf8Gfc6ocQ --- UnityCtl.Cli/BridgeClient.cs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/UnityCtl.Cli/BridgeClient.cs b/UnityCtl.Cli/BridgeClient.cs index a6ef27b..dbc7a20 100644 --- a/UnityCtl.Cli/BridgeClient.cs +++ b/UnityCtl.Cli/BridgeClient.cs @@ -22,13 +22,7 @@ public BridgeClient(string baseUrl, string? agentId = null, string? projectRoot _baseUrl = baseUrl; _agentId = agentId; _projectRoot = projectRoot; - _httpClient = new HttpClient - { - BaseAddress = new Uri(baseUrl), - // The bridge enforces per-command timeouts server-side. - // The CLI should not race against those with a shorter HTTP timeout. - Timeout = TimeSpan.FromMinutes(15) - }; + _httpClient = new HttpClient { BaseAddress = new Uri(baseUrl) }; } public static BridgeClient? TryCreateFromProject(string? projectPath, string? agentId) @@ -139,7 +133,15 @@ public BridgeClient(string baseUrl, string? agentId = null, string? projectRoot 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) { From 0062b2d1e0c55177996b9776c8016b9094dc7eab Mon Sep 17 00:00:00 2001 From: Martin Vagstad Date: Thu, 19 Feb 2026 19:54:09 +0100 Subject: [PATCH 5/6] Add --timeout flag to script eval and script execute commands Exposes the per-request timeout override through the CLI so users can specify longer timeouts for long-running operations like player builds. --- UnityCtl.Cli/BridgeClient.cs | 1 + UnityCtl.Cli/ScriptCommands.cs | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/UnityCtl.Cli/BridgeClient.cs b/UnityCtl.Cli/BridgeClient.cs index dbc7a20..2b72fd1 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; 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) From 402eda3a4655364367fcbe7dd91e3cb8ce30db8d Mon Sep 17 00:00:00 2001 From: Martin Vagstad Date: Thu, 19 Feb 2026 20:11:55 +0100 Subject: [PATCH 6/6] Fix HttpClient.Timeout racing per-request timeout, document -t flag in skill HttpClient defaults to 100s timeout which fires before the per-request CancellationTokenSource for any -t value above ~70s. Use InfiniteTimeSpan so the bridge's server-side timeout is the sole authority. --- .claude/skills/unity-editor/SKILL.md | 6 ++++++ UnityCtl.Cli/BridgeClient.cs | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) 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.Cli/BridgeClient.cs b/UnityCtl.Cli/BridgeClient.cs index 2b72fd1..1dbb578 100644 --- a/UnityCtl.Cli/BridgeClient.cs +++ b/UnityCtl.Cli/BridgeClient.cs @@ -23,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)