diff --git a/.agents/skills/uloop-compile/SKILL.md b/.agents/skills/uloop-compile/SKILL.md index 3febf8930..265987c4c 100644 --- a/.agents/skills/uloop-compile/SKILL.md +++ b/.agents/skills/uloop-compile/SKILL.md @@ -11,7 +11,7 @@ Execute Unity project compilation. ## Usage ```bash -uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-external-scene-changes] +uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-external-scene-changes] [--compile-wait-timeout-seconds ] ``` ## Parameters @@ -21,6 +21,7 @@ uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-exter | `--force-recompile` | flag | - | Full recompile plus domain reload. Almost never needed: a plain compile already detects externally edited files, and the forced reload can freeze large projects and come back as `COMPILE_RESULT_UNKNOWN`. | | `--no-wait-for-domain-reload` | flag | - | Return before Domain Reload completion | | `--stop-on-external-scene-changes` | flag | - | Stop before compilation if open Scene files changed externally instead of auto-reloading them | +| `--compile-wait-timeout-seconds` | integer | 600 | Maximum seconds the CLI waits for the compile to finish before returning COMPILE_WAIT_TIMEOUT (default 600). Values above 1200 exceed the Unity-side result retention window (20 minutes) and weaken post-timeout recovery. | ## When to use --force-recompile diff --git a/.claude/skills/uloop-compile/SKILL.md b/.claude/skills/uloop-compile/SKILL.md index 3febf8930..265987c4c 100644 --- a/.claude/skills/uloop-compile/SKILL.md +++ b/.claude/skills/uloop-compile/SKILL.md @@ -11,7 +11,7 @@ Execute Unity project compilation. ## Usage ```bash -uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-external-scene-changes] +uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-external-scene-changes] [--compile-wait-timeout-seconds ] ``` ## Parameters @@ -21,6 +21,7 @@ uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-exter | `--force-recompile` | flag | - | Full recompile plus domain reload. Almost never needed: a plain compile already detects externally edited files, and the forced reload can freeze large projects and come back as `COMPILE_RESULT_UNKNOWN`. | | `--no-wait-for-domain-reload` | flag | - | Return before Domain Reload completion | | `--stop-on-external-scene-changes` | flag | - | Stop before compilation if open Scene files changed externally instead of auto-reloading them | +| `--compile-wait-timeout-seconds` | integer | 600 | Maximum seconds the CLI waits for the compile to finish before returning COMPILE_WAIT_TIMEOUT (default 600). Values above 1200 exceed the Unity-side result retention window (20 minutes) and weaken post-timeout recovery. | ## When to use --force-recompile diff --git a/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs b/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs index 4bc546da4..6f153a042 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs +++ b/Packages/src/Editor/FirstPartyTools/Compile/CompileSchema.cs @@ -25,6 +25,13 @@ public class CompileSchema : UnityCliLoopToolSchema /// public bool ReloadExternalSceneChanges { get; set; } = true; + /// + /// How long the CLI waits for compilation to complete, in seconds. + /// Unity ignores this value; it is consumed by the CLI. + /// + [Description("How long the CLI waits for compilation to complete, in seconds. Unity ignores this value; it is consumed by the CLI.")] + public int CompileWaitTimeoutSeconds { get; set; } = 600; + /// /// Internal request identifier used for delayed result recovery across domain reload. /// diff --git a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md index 3febf8930..265987c4c 100644 --- a/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/Compile/Skill/SKILL.md @@ -11,7 +11,7 @@ Execute Unity project compilation. ## Usage ```bash -uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-external-scene-changes] +uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-external-scene-changes] [--compile-wait-timeout-seconds ] ``` ## Parameters @@ -21,6 +21,7 @@ uloop compile [--force-recompile] [--no-wait-for-domain-reload] [--stop-on-exter | `--force-recompile` | flag | - | Full recompile plus domain reload. Almost never needed: a plain compile already detects externally edited files, and the forced reload can freeze large projects and come back as `COMPILE_RESULT_UNKNOWN`. | | `--no-wait-for-domain-reload` | flag | - | Return before Domain Reload completion | | `--stop-on-external-scene-changes` | flag | - | Stop before compilation if open Scene files changed externally instead of auto-reloading them | +| `--compile-wait-timeout-seconds` | integer | 600 | Maximum seconds the CLI waits for the compile to finish before returning COMPILE_WAIT_TIMEOUT (default 600). Values above 1200 exceed the Unity-side result retention window (20 minutes) and weaken post-timeout recovery. | ## When to use --force-recompile diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index df36cb6ff..514c045bb 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -19,6 +19,11 @@ "type": "boolean", "description": "Stop before compilation if open Scene files changed externally instead of auto-reloading them", "default": true + }, + "CompileWaitTimeoutSeconds": { + "type": "integer", + "description": "Maximum seconds the CLI waits for the compile to finish before returning COMPILE_WAIT_TIMEOUT (default 600). Values above 1200 exceed the Unity-side result retention window (20 minutes) and weaken post-timeout recovery.", + "default": 600 } } } diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 5750f15eb..38d5012ab 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "0313515f1add4e1c394e1957c0aff7215e39e678" + "sharedInputsHash": "9623e4986ab240e4a131ac65128f62aea9da1f99" } diff --git a/cli/project-runner/internal/projectrunner/compile_wait.go b/cli/project-runner/internal/projectrunner/compile_wait.go index 515b9a0cf..3af800959 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait.go +++ b/cli/project-runner/internal/projectrunner/compile_wait.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "math" "time" clierrors "github.com/hatayama/unity-cli-loop/common/errors" @@ -22,13 +23,21 @@ const ( compileRequestIDParam = "RequestId" compileWaitParam = clicore.DomainReloadWaitParam compileForceParam = "ForceRecompile" + compileWaitTimeoutParam = "CompileWaitTimeoutSeconds" // Why separate from ToolReadinessTimeout (180s): launch readiness stays short. // Why 10m: worst-case blind block beats headroom. Why ≤ C# CompileResultLifetime (20m): // timed-out clients can still retrieve results by retrying uloop compile ~10m more. - compileWaitTimeout = 10 * time.Minute - compileWaitPollInterval = clicore.ToolReadinessPoll - compileStatusProbeTimeout = clicore.ToolReadinessProbeTimeout - compileResponseTimeout = 2 * time.Second + compileWaitTimeout = 10 * time.Minute + // Why warn at 20m: Unity stores compile results for CompileResultLifetime (20m). + // Waiting longer does not fail, but a timed-out retry may miss the retained result. + compileWaitTimeoutRetentionWarningSeconds = 1200 + // Why: time.Duration is int64 nanoseconds. Values above this overflow to a negative + // duration when multiplied by time.Second, which would look like an immediate timeout. + // This is an overflow guard, not a product-imposed maximum wait. + compileWaitTimeoutMaxSeconds = int64(math.MaxInt64 / int64(time.Second)) + compileWaitPollInterval = clicore.ToolReadinessPoll + compileStatusProbeTimeout = clicore.ToolReadinessProbeTimeout + compileResponseTimeout = 2 * time.Second ) type compileCompletionOptions struct { @@ -66,6 +75,59 @@ func prepareCompileWaitParams(params map[string]any) (string, error) { return requestID, nil } +// compileWaitTimeoutFromParams reads CompileWaitTimeoutSeconds from tool params. +// Missing values keep the default compileWaitTimeout (10m). Non-positive or non-integer +// values are rejected before a compile request is sent. +func compileWaitTimeoutFromParams(params map[string]any) (time.Duration, error) { + value, exists := params[compileWaitTimeoutParam] + if !exists || value == nil { + return compileWaitTimeout, nil + } + + seconds, ok := positiveInt64FromAny(value) + if !ok || seconds > compileWaitTimeoutMaxSeconds { + return 0, clierrors.InvalidValueArgumentError( + "--compile-wait-timeout-seconds", + fmt.Sprint(value), + "positive integer", + ) + } + return time.Duration(seconds) * time.Second, nil +} + +func positiveInt64FromAny(value any) (int64, bool) { + switch typed := value.(type) { + case int: + if typed <= 0 { + return 0, false + } + return int64(typed), true + case int32: + if typed <= 0 { + return 0, false + } + return int64(typed), true + case int64: + if typed <= 0 { + return 0, false + } + return typed, true + case float64: + if typed <= 0 || typed != math.Trunc(typed) || typed > float64(compileWaitTimeoutMaxSeconds) { + return 0, false + } + return int64(typed), true + case json.Number: + parsed, err := typed.Int64() + if err != nil || parsed <= 0 { + return 0, false + } + return parsed, true + default: + return 0, false + } +} + func ensureCompileRequestID(params map[string]any) (string, error) { if value, ok := params[compileRequestIDParam].(string); ok && value != "" { if isSafeCompileRequestID(value) { @@ -230,6 +292,7 @@ func logCompileRequestPrepared( connection unityipc.Connection, params map[string]any, requestID string, + waitTimeout time.Duration, ) { writeCompileVibeLog(connection.ProjectRoot, func() vibelog.CLIVibeLogEntry { reloadExternalSceneChanges := compileReloadExternalSceneChangesEnabled(params) @@ -246,7 +309,7 @@ func logCompileRequestPrepared( "stop_on_external_scene_changes": !reloadExternalSceneChanges, "project_identity": vibelog.ProjectIdentity(connection.ProjectRoot), "endpoint": connection.Endpoint.Address, - "timeout_ms": compileWaitTimeout.Milliseconds(), + "timeout_ms": waitTimeout.Milliseconds(), "poll_interval_ms": compileWaitPollInterval.Milliseconds(), "response_timeout_ms": compileResponseTimeout.Milliseconds(), }, diff --git a/cli/project-runner/internal/projectrunner/compile_wait_test.go b/cli/project-runner/internal/projectrunner/compile_wait_test.go index 7c80852b3..7775a14aa 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait_test.go +++ b/cli/project-runner/internal/projectrunner/compile_wait_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "net" + "path/filepath" "runtime" "strings" "testing" @@ -14,6 +15,7 @@ import ( clierrors "github.com/hatayama/unity-cli-loop/common/errors" "github.com/hatayama/unity-cli-loop/common/tooldocs" + "github.com/hatayama/unity-cli-loop/common/vibelog" "github.com/hatayama/unity-cli-loop/common/clicore" "github.com/hatayama/unity-cli-loop/common/unityipc" @@ -411,42 +413,7 @@ func TestRunCompileWithDomainReloadWaitWritesRequestLifecycleVibeLogs(t *testing } enableCliVibeLog(t) - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("failed to listen: %v", err) - } - defer func() { - _ = listener.Close() - }() - - serverErr := make(chan error, 1) - go func() { - conn, err := listener.Accept() - if err != nil { - serverErr <- err - return - } - defer func() { - _ = conn.Close() - }() - - if _, err := unityipc.Read(bufio.NewReader(conn)); err != nil { - serverErr <- err - return - } - - accepted := []byte(`{"jsonrpc":"2.0","result":{"accepted":true},"uloop":{"phase":"accepted"},"id":1}`) - if err := unityipc.Write(conn, accepted); err != nil { - serverErr <- err - return - } - - final := []byte(`{"jsonrpc":"2.0","result":{"Accepted":true},"id":1}`) - if err := unityipc.Write(conn, final); err != nil { - serverErr <- err - return - } - }() + endpoint, serverErr := startCompileAcceptOnceServer(t) deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { return compileStatusResponse{ @@ -458,10 +425,7 @@ func TestRunCompileWithDomainReloadWaitWritesRequestLifecycleVibeLogs(t *testing projectRoot := t.TempDir() connection := unityipc.Connection{ - Endpoint: unityipc.Endpoint{ - Network: "tcp", - Address: listener.Addr().String(), - }, + Endpoint: endpoint, ProjectRoot: projectRoot, } params := map[string]any{ @@ -487,6 +451,7 @@ func TestRunCompileWithDomainReloadWaitWritesRequestLifecycleVibeLogs(t *testing `"response_received":true`, `"force_recompile":true`, `"reload_external_scene_changes":false`, + `"timeout_ms":600000`, } { if !strings.Contains(logContent, expected) { t.Fatalf("CLI Vibe log missing %q:\n%s", expected, logContent) @@ -500,6 +465,183 @@ func TestRunCompileWithDomainReloadWaitWritesRequestLifecycleVibeLogs(t *testing } } +// Verifies runCompileWithDomainReloadWaitWithDeps wires CompileWaitTimeoutSeconds into +// the wait deadline and COMPILE_WAIT_TIMEOUT message (not only the wait helper itself). +func TestRunCompileWithDomainReloadWaitUsesConfiguredTimeout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("TCP endpoint injection is only used by this non-Windows client test") + } + + enableCliVibeLog(t) + endpoint, serverErr := startCompileAcceptOnceServer(t) + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + return compileStatusResponse{Ready: false, IsCompiling: true, Message: "Compiling"}, nil + }) + + projectRoot := t.TempDir() + connection := unityipc.Connection{ + Endpoint: endpoint, + ProjectRoot: projectRoot, + } + params := map[string]any{ + compileWaitTimeoutParam: 1, + } + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runCompileWithDomainReloadWaitWithDeps(context.Background(), connection, params, &stdout, &stderr, deps) + if code != 1 { + t.Fatalf("expected timeout exit 1: code=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "Compile status wait timed out after 1000ms") { + t.Fatalf("timeout message missing configured duration: %s", stderr.String()) + } + if !strings.Contains(stderr.String(), `"ErrorCode": "COMPILE_WAIT_TIMEOUT"`) && + !strings.Contains(stderr.String(), `"ErrorCode":"COMPILE_WAIT_TIMEOUT"`) { + t.Fatalf("expected COMPILE_WAIT_TIMEOUT envelope: %s", stderr.String()) + } + + logContent := readOnlyCliVibeLog(t, projectRoot) + if !strings.Contains(logContent, `"timeout_ms":1000`) { + t.Fatalf("prepared vibe log should record configured timeout_ms=1000:\n%s", logContent) + } + + select { + case err := <-serverErr: + t.Fatalf("server failed: %v", err) + default: + } +} + +// Verifies invalid CompileWaitTimeoutSeconds fails before a compile request is dispatched. +func TestRunCompileWithDomainReloadWaitRejectsNonPositiveTimeout(t *testing.T) { + enableCliVibeLog(t) + projectRoot := t.TempDir() + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: "tcp", + Address: "127.0.0.1:1", + }, + ProjectRoot: projectRoot, + } + params := map[string]any{ + compileWaitTimeoutParam: 0, + } + var stdout bytes.Buffer + var stderr bytes.Buffer + queryCalls := 0 + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + queryCalls++ + return compileStatusResponse{}, nil + }) + + code := runCompileWithDomainReloadWaitWithDeps(context.Background(), connection, params, &stdout, &stderr, deps) + if code != 1 { + t.Fatalf("expected invalid timeout exit 1: code=%d stderr=%s", code, stderr.String()) + } + if queryCalls != 0 { + t.Fatalf("compile status must not be queried for invalid timeout: calls=%d", queryCalls) + } + logFiles, err := filepath.Glob(filepath.Join(projectRoot, vibelog.CLIVibeLogDirectory, vibelog.CLIVibeLogPrefix+"_*.json")) + if err != nil { + t.Fatalf("failed to glob CLI Vibe logs: %v", err) + } + if len(logFiles) != 0 { + t.Fatalf("compile request must not write vibe logs for invalid timeout: %#v", logFiles) + } + if !strings.Contains(stderr.String(), "positive integer") { + t.Fatalf("expected positive integer validation error: %s", stderr.String()) + } +} + +// Verifies timeouts above the Unity result retention window warn on stderr before compile proceeds. +func TestRunCompileWithDomainReloadWaitWarnsWhenTimeoutExceedsRetention(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("TCP endpoint injection is only used by this non-Windows client test") + } + + endpoint, serverErr := startCompileAcceptOnceServer(t) + // Why Success:false: Success:true triggers post-compile warmup against a live Editor + // and would hang this unit test. The warning is emitted before send/wait. + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + return compileStatusResponse{ + Ready: true, + HasResult: true, + Result: json.RawMessage(`{"Success":false,"ErrorCount":1,"WarningCount":0}`), + }, nil + }) + + connection := unityipc.Connection{ + Endpoint: endpoint, + ProjectRoot: t.TempDir(), + } + params := map[string]any{ + compileWaitTimeoutParam: compileWaitTimeoutRetentionWarningSeconds + 1, + } + var stdout bytes.Buffer + var stderr bytes.Buffer + + code := runCompileWithDomainReloadWaitWithDeps(context.Background(), connection, params, &stdout, &stderr, deps) + if code != 1 { + t.Fatalf("expected failed compile envelope exit 1: code=%d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + if !strings.Contains(stderr.String(), "exceeds the Unity-side compile result retention window (20 minutes)") { + t.Fatalf("expected retention warning on stderr: %s", stderr.String()) + } + + select { + case err := <-serverErr: + t.Fatalf("server failed: %v", err) + default: + } +} + +// startCompileAcceptOnceServer accepts one compile IPC session and returns accepted + final responses. +func startCompileAcceptOnceServer(t *testing.T) (unityipc.Endpoint, <-chan error) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { + _ = listener.Close() + }) + + serverErr := make(chan error, 1) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + serverErr <- acceptErr + return + } + defer func() { + _ = conn.Close() + }() + + if _, readErr := unityipc.Read(bufio.NewReader(conn)); readErr != nil { + serverErr <- readErr + return + } + + accepted := []byte(`{"jsonrpc":"2.0","result":{"accepted":true},"uloop":{"phase":"accepted"},"id":1}`) + if writeErr := unityipc.Write(conn, accepted); writeErr != nil { + serverErr <- writeErr + return + } + + final := []byte(`{"jsonrpc":"2.0","result":{"Accepted":true},"id":1}`) + if writeErr := unityipc.Write(conn, final); writeErr != nil { + serverErr <- writeErr + return + } + }() + + return unityipc.Endpoint{ + Network: "tcp", + Address: listener.Addr().String(), + }, serverErr +} + func TestShouldWaitForCompileStatusRequiresDispatchedTransportError(t *testing.T) { if shouldWaitForCompileStatus(fmt.Errorf("missing"), unityipc.UnitySendOutcome{}) { t.Fatal("undispatched error should not wait") @@ -552,11 +694,121 @@ func TestWritePostCompileWarmupWarningReportsNonFatalFailure(t *testing.T) { } } +// Verifies CompileWaitTimeoutSeconds is parsed from tool params with the default +// kept when absent and non-positive or non-integer values rejected. +func TestCompileWaitTimeoutFromParams(t *testing.T) { + cases := []struct { + name string + params map[string]any + want time.Duration + wantErr bool + }{ + { + name: "missing uses default", + params: map[string]any{}, + want: compileWaitTimeout, + }, + { + name: "nil value uses default", + params: map[string]any{compileWaitTimeoutParam: nil}, + want: compileWaitTimeout, + }, + { + name: "int value", + params: map[string]any{compileWaitTimeoutParam: 90}, + want: 90 * time.Second, + }, + { + name: "float64 whole number", + params: map[string]any{compileWaitTimeoutParam: float64(120)}, + want: 120 * time.Second, + }, + { + name: "json.Number", + params: map[string]any{compileWaitTimeoutParam: json.Number("45")}, + want: 45 * time.Second, + }, + { + name: "zero is rejected", + params: map[string]any{compileWaitTimeoutParam: 0}, + wantErr: true, + }, + { + name: "negative is rejected", + params: map[string]any{compileWaitTimeoutParam: -1}, + wantErr: true, + }, + { + name: "non-integer float is rejected", + params: map[string]any{compileWaitTimeoutParam: 1.5}, + wantErr: true, + }, + { + name: "max representable duration is accepted", + params: map[string]any{compileWaitTimeoutParam: compileWaitTimeoutMaxSeconds}, + want: time.Duration(compileWaitTimeoutMaxSeconds) * time.Second, + }, + { + name: "overflowing duration is rejected", + params: map[string]any{compileWaitTimeoutParam: compileWaitTimeoutMaxSeconds + 1}, + wantErr: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := compileWaitTimeoutFromParams(tc.params) + if tc.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("timeout mismatch: got %v want %v", got, tc.want) + } + }) + } +} + +// Verifies waitForCompileCompletionWithDeps stops when the configured timeout elapses +// instead of waiting for the default compileWaitTimeout. +func TestWaitForCompileCompletionRespectsConfiguredTimeout(t *testing.T) { + connection := compileWaitTestConnection(t) + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + return compileStatusResponse{Ready: false, IsCompiling: true}, nil + }) + + startedAt := time.Now() + _, completed, err := waitForCompileCompletionWithDeps(context.Background(), compileCompletionOptions{ + connection: connection, + requestID: "compile_configured_timeout", + timeout: 40 * time.Millisecond, + pollInterval: 5 * time.Millisecond, + }, deps) + elapsed := time.Since(startedAt) + if err != nil { + t.Fatalf("waitForCompileCompletion failed: %v", err) + } + if completed { + t.Fatal("compile wait should time out while still compiling") + } + if elapsed < 40*time.Millisecond { + t.Fatalf("timed out too early: %v", elapsed) + } + if elapsed > 500*time.Millisecond { + t.Fatalf("timed out too late for a 40ms deadline: %v", elapsed) + } +} + // Tests that compile wait timeout guidance teaches the caller to verify Editor // responsiveness instead of assuming a freeze, because agents have terminated // whole sessions after misreading this timeout as a frozen Editor. func TestCompileWaitTimeoutError(t *testing.T) { - cliErr := compileWaitTimeoutError("/tmp/MyProject") + cliErr := compileWaitTimeoutError("/tmp/MyProject", 90*time.Second) if cliErr.ErrorCode != clierrors.ErrorCodeCompileWaitTimeout { t.Fatalf("error code mismatch: %#v", cliErr) @@ -567,7 +819,7 @@ func TestCompileWaitTimeoutError(t *testing.T) { if cliErr.ProjectRoot != "/tmp/MyProject" { t.Fatalf("project root mismatch: %#v", cliErr) } - expectedMessage := "Compile status wait timed out after 600000ms. This does not mean the Unity Editor is frozen; the compile may simply still be running." + expectedMessage := "Compile status wait timed out after 90000ms. This does not mean the Unity Editor is frozen; the compile may simply still be running." if cliErr.Message != expectedMessage { t.Fatalf("message mismatch: %#v", cliErr.Message) } diff --git a/cli/project-runner/internal/projectrunner/execution_errors.go b/cli/project-runner/internal/projectrunner/execution_errors.go index a1bf1261d..3f384cb44 100644 --- a/cli/project-runner/internal/projectrunner/execution_errors.go +++ b/cli/project-runner/internal/projectrunner/execution_errors.go @@ -2,19 +2,20 @@ package projectrunner import ( "fmt" + "time" clierrors "github.com/hatayama/unity-cli-loop/common/errors" "github.com/hatayama/unity-cli-loop/common/clicore" ) -func compileWaitTimeoutError(projectRoot string) clierrors.CLIError { +func compileWaitTimeoutError(projectRoot string, timeout time.Duration) clierrors.CLIError { return clierrors.CLIError{ ErrorCode: clierrors.ErrorCodeCompileWaitTimeout, Phase: clierrors.ErrorPhaseCompileWaiting, Message: fmt.Sprintf( "Compile status wait timed out after %dms. This does not mean the Unity Editor is frozen; the compile may simply still be running.", - compileWaitTimeout.Milliseconds()), + timeout.Milliseconds()), Retryable: true, SafeToRetry: true, ProjectRoot: projectRoot, diff --git a/cli/project-runner/internal/projectrunner/run.go b/cli/project-runner/internal/projectrunner/run.go index 39b86ede2..9eee4a6f7 100644 --- a/cli/project-runner/internal/projectrunner/run.go +++ b/cli/project-runner/internal/projectrunner/run.go @@ -174,8 +174,23 @@ func runCompileWithDomainReloadWaitWithDeps( return 1 } + waitTimeout, timeoutErr := compileWaitTimeoutFromParams(params) + if timeoutErr != nil { + clierrors.WriteClassifiedError(stderr, timeoutErr, clierrors.ErrorContext{ + ProjectRoot: connection.ProjectRoot, + Command: clicore.CompileCommandName, + }) + return 1 + } + if waitTimeout > time.Duration(compileWaitTimeoutRetentionWarningSeconds)*time.Second { + _, _ = fmt.Fprintf( + stderr, + "warning: --compile-wait-timeout-seconds exceeds the Unity-side compile result retention window (20 minutes); if the wait times out, the result may expire before a retry can recover it.\n", + ) + } + logCliDebugModeResolved(connection, clicore.CompileCommandName) - logCompileRequestPrepared(connection, params, requestID) + logCompileRequestPrepared(connection, params, requestID, waitTimeout) startedAt := time.Now() spinner := clicore.NewToolSpinner(stderr, clicore.CompileCommandName) @@ -205,7 +220,7 @@ func runCompileWithDomainReloadWaitWithDeps( connection: connection, requestID: requestID, forceRecompile: compileForceRecompileEnabled(params), - timeout: compileWaitTimeout, + timeout: waitTimeout, pollInterval: compileWaitPollInterval, }, compileWait) if waitErr != nil { @@ -218,7 +233,7 @@ func runCompileWithDomainReloadWaitWithDeps( } if !completed { spinner.Stop() - clierrors.WriteErrorEnvelope(stderr, compileWaitTimeoutError(connection.ProjectRoot)) + clierrors.WriteErrorEnvelope(stderr, compileWaitTimeoutError(connection.ProjectRoot, waitTimeout)) return 1 } switch compileResultReadinessWaitMode(result) { diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 086c6efc9..7f6b07fd6 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "b85b6271eda91daf75fb620f635266c7a709f90b" + "sharedInputsHash": "a4acb2ec0d1e37d45a3ed8a36448929214310be0" }