From dcffe13f45ac2a2c2a05dea99b3c9cbb72114cd4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 13 Jul 2026 14:09:20 +0900 Subject: [PATCH 1/4] Bound Go process-list external commands with context timeouts ps and PowerShell Get-CimInstance can hang indefinitely when WMI stalls. Apply a 10-second cap inside unityprocess and use the same bound when tool readiness classifies a live Unity process after timeout. Co-authored-by: Cursor --- cli/common/clicore/tool_readiness.go | 4 ++- cli/common/clicore/tool_readiness_test.go | 11 ++++++- cli/common/unityprocess/process.go | 8 +++-- cli/common/unityprocess/timeouts.go | 17 ++++++++++ cli/common/unityprocess/timeouts_test.go | 40 +++++++++++++++++++++++ 5 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 cli/common/unityprocess/timeouts.go create mode 100644 cli/common/unityprocess/timeouts_test.go diff --git a/cli/common/clicore/tool_readiness.go b/cli/common/clicore/tool_readiness.go index a9757f29a..9686e0697 100644 --- a/cli/common/clicore/tool_readiness.go +++ b/cli/common/clicore/tool_readiness.go @@ -80,7 +80,9 @@ func toolReadinessDoneErrorWithDeps(ctx context.Context, projectRoot string, cau if err := ctx.Err(); err != nil { return err } - runningProcess, err := deps.findRunningUnityProcess(context.Background(), projectRoot) + processLookupContext, cancel := context.WithTimeout(context.Background(), unityprocess.ProcessListCommandTimeout) + defer cancel() + runningProcess, err := deps.findRunningUnityProcess(processLookupContext, projectRoot) if err == nil && runningProcess != nil { return clierrors.UnityServerNotRespondingError{ ProjectRoot: projectRoot, diff --git a/cli/common/clicore/tool_readiness_test.go b/cli/common/clicore/tool_readiness_test.go index 9e9b305c0..bad93a655 100644 --- a/cli/common/clicore/tool_readiness_test.go +++ b/cli/common/clicore/tool_readiness_test.go @@ -9,6 +9,7 @@ import ( clierrors "github.com/hatayama/unity-cli-loop/common/errors" "github.com/hatayama/unity-cli-loop/common/unityipc" + "github.com/hatayama/unity-cli-loop/common/unityprocess" ) // Verifies shared readiness waits keep the shorter non-launch timeout. @@ -82,7 +83,15 @@ func TestToolReadinessDoneErrorReportsTimeoutWhenParentIsActive(t *testing.T) { // Verifies that readiness timeout reports a live Unity process whose IPC server does not respond. func TestToolReadinessDoneErrorReportsServerNotRespondingWhenUnityRuns(t *testing.T) { deps := toolReadinessDeps{ - findRunningUnityProcess: func(context.Context, string) (*UnityProcess, error) { + findRunningUnityProcess: func(ctx context.Context, projectRoot string) (*UnityProcess, error) { + deadline, hasDeadline := ctx.Deadline() + if !hasDeadline { + t.Fatal("process lookup context should have a deadline") + } + remaining := time.Until(deadline) + if remaining < unityprocess.ProcessListCommandTimeout-time.Second || remaining > unityprocess.ProcessListCommandTimeout { + t.Fatalf("process lookup timeout mismatch: %s", remaining) + } return &UnityProcess{Pid: 123}, nil }, } diff --git a/cli/common/unityprocess/process.go b/cli/common/unityprocess/process.go index 99b167bcb..dc023eacd 100644 --- a/cli/common/unityprocess/process.go +++ b/cli/common/unityprocess/process.go @@ -64,7 +64,9 @@ func listUnityProcesses(ctx context.Context) ([]UnityProcess, error) { } func listUnityProcessesMac(ctx context.Context) ([]UnityProcess, error) { - output, err := exec.CommandContext(ctx, "ps", "-axo", "pid=,command=", "-ww").Output() + commandContext, cancel := withCommandTimeout(ctx, ProcessListCommandTimeout) + defer cancel() + output, err := exec.CommandContext(commandContext, "ps", "-axo", "pid=,command=", "-ww").Output() if err != nil { return nil, fmt.Errorf("failed to retrieve Unity process list: %w", err) } @@ -80,7 +82,9 @@ func listUnityProcessesWindows(ctx context.Context) ([]UnityProcess, error) { " Write-Output (\"{0}|{1}\" -f $process.ProcessId, $commandLine)", "}", } - output, err := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", strings.Join(scriptLines, "\n")).Output() + commandContext, cancel := withCommandTimeout(ctx, ProcessListCommandTimeout) + defer cancel() + output, err := exec.CommandContext(commandContext, windowsPowerShellCommand, "-NoProfile", "-Command", strings.Join(scriptLines, "\n")).Output() if err != nil { return nil, fmt.Errorf("failed to retrieve Unity process list on Windows: %w", err) } diff --git a/cli/common/unityprocess/timeouts.go b/cli/common/unityprocess/timeouts.go new file mode 100644 index 000000000..c536c4561 --- /dev/null +++ b/cli/common/unityprocess/timeouts.go @@ -0,0 +1,17 @@ +package unityprocess + +import ( + "context" + "time" +) + +const ( + // ProcessListCommandTimeout bounds ps and PowerShell process-enumeration calls that can hang on WMI stalls. + ProcessListCommandTimeout = 10 * time.Second + // FocusCommandTimeout bounds osascript and PowerShell focus calls that can hang on permission dialogs. + FocusCommandTimeout = 10 * time.Second +) + +func withCommandTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, timeout) +} diff --git a/cli/common/unityprocess/timeouts_test.go b/cli/common/unityprocess/timeouts_test.go new file mode 100644 index 000000000..7be0830b1 --- /dev/null +++ b/cli/common/unityprocess/timeouts_test.go @@ -0,0 +1,40 @@ +package unityprocess + +import ( + "context" + "testing" + "time" +) + +// Verifies external-command timeouts use the parent deadline when it is sooner than the command cap. +func TestWithCommandTimeoutRespectsEarlierParentDeadline(t *testing.T) { + parentContext, parentCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer parentCancel() + + commandContext, cancel := withCommandTimeout(parentContext, ProcessListCommandTimeout) + defer cancel() + + parentDeadline, parentHasDeadline := parentContext.Deadline() + commandDeadline, commandHasDeadline := commandContext.Deadline() + if !parentHasDeadline || !commandHasDeadline { + t.Fatal("expected both parent and command contexts to have deadlines") + } + if !commandDeadline.Equal(parentDeadline) { + t.Fatalf("command deadline %s should match parent deadline %s", commandDeadline, parentDeadline) + } +} + +// Verifies external-command timeouts still bound background callers that pass no deadline. +func TestWithCommandTimeoutBoundsBackgroundContext(t *testing.T) { + commandContext, cancel := withCommandTimeout(context.Background(), ProcessListCommandTimeout) + defer cancel() + + deadline, hasDeadline := commandContext.Deadline() + if !hasDeadline { + t.Fatal("expected command context to have a deadline") + } + remaining := time.Until(deadline) + if remaining < ProcessListCommandTimeout-time.Second || remaining > ProcessListCommandTimeout { + t.Fatalf("command timeout mismatch: %s", remaining) + } +} From 2e6ffc3427fb9fbdddc33fcdb629bd551ca7ff72 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 13 Jul 2026 14:10:10 +0900 Subject: [PATCH 2/4] Bound Go focus external commands with context timeouts osascript and PowerShell focus calls can hang on permission dialogs and compound Unity recovery stalls. Cap them at 10 seconds in unityprocess and bound connection-retry focus rescue with the same deadline. Co-authored-by: Cursor --- cli/common/unityprocess/focus_darwin.go | 8 +++-- cli/common/unityprocess/focus_windows.go | 12 ++++++-- .../projectrunner/connection_retry.go | 4 ++- .../projectrunner/connection_retry_test.go | 29 +++++++++++++++++++ 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/cli/common/unityprocess/focus_darwin.go b/cli/common/unityprocess/focus_darwin.go index aa6d76351..7beaa233d 100644 --- a/cli/common/unityprocess/focus_darwin.go +++ b/cli/common/unityprocess/focus_darwin.go @@ -28,7 +28,9 @@ func FocusUnityProcessWithRestore(ctx context.Context, pid int) (RestoreFocusFun } func readFrontmostProcessIDMac(ctx context.Context) int { - output, err := exec.CommandContext(ctx, "osascript", "-e", `tell application "System Events" to get unix id of first process whose frontmost is true`).Output() + commandContext, cancel := withCommandTimeout(ctx, FocusCommandTimeout) + defer cancel() + output, err := exec.CommandContext(commandContext, "osascript", "-e", `tell application "System Events" to get unix id of first process whose frontmost is true`).Output() if err != nil { return 0 } @@ -40,6 +42,8 @@ func readFrontmostProcessIDMac(ctx context.Context) int { } func setFrontmostProcessMac(ctx context.Context, pid int) error { + commandContext, cancel := withCommandTimeout(ctx, FocusCommandTimeout) + defer cancel() script := fmt.Sprintf(`tell application "System Events" to set frontmost of (first process whose unix id is %d) to true`, pid) - return exec.CommandContext(ctx, "osascript", "-e", script).Run() + return exec.CommandContext(commandContext, "osascript", "-e", script).Run() } diff --git a/cli/common/unityprocess/focus_windows.go b/cli/common/unityprocess/focus_windows.go index 53fc89744..7591fc6b0 100644 --- a/cli/common/unityprocess/focus_windows.go +++ b/cli/common/unityprocess/focus_windows.go @@ -9,9 +9,11 @@ import ( ) func FocusUnityProcess(ctx context.Context, pid int) error { + commandContext, cancel := withCommandTimeout(ctx, FocusCommandTimeout) + defer cancel() script := buildFocusUnityProcessWindowsScript(pid) stderr := bytes.Buffer{} - command := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script) + command := exec.CommandContext(commandContext, windowsPowerShellCommand, "-NoProfile", "-Command", script) command.Stderr = &stderr if err := command.Run(); err != nil { return commandErrorWithStderr(err, stderr.String()) @@ -20,9 +22,11 @@ func FocusUnityProcess(ctx context.Context, pid int) error { } func FocusUnityProcessWithRestore(ctx context.Context, pid int) (RestoreFocusFunc, error) { + commandContext, cancel := withCommandTimeout(ctx, FocusCommandTimeout) + defer cancel() script := buildFocusUnityProcessWindowsWithRestoreScript(pid) stderr := bytes.Buffer{} - command := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script) + command := exec.CommandContext(commandContext, windowsPowerShellCommand, "-NoProfile", "-Command", script) command.Stderr = &stderr output, err := command.Output() if err != nil { @@ -38,9 +42,11 @@ func FocusUnityProcessWithRestore(ctx context.Context, pid int) (RestoreFocusFun } func restoreWindowsForegroundWindow(ctx context.Context, handle int64) error { + commandContext, cancel := withCommandTimeout(ctx, FocusCommandTimeout) + defer cancel() script := buildRestoreWindowsForegroundWindowScript(handle) stderr := bytes.Buffer{} - command := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script) + command := exec.CommandContext(commandContext, windowsPowerShellCommand, "-NoProfile", "-Command", script) command.Stderr = &stderr if err := command.Run(); err != nil { return commandErrorWithStderr(err, stderr.String()) diff --git a/cli/project-runner/internal/projectrunner/connection_retry.go b/cli/project-runner/internal/projectrunner/connection_retry.go index 649f1a851..1ac03cd37 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry.go +++ b/cli/project-runner/internal/projectrunner/connection_retry.go @@ -128,7 +128,9 @@ func (controller *connectionRetryFocusController) tryFocusProcess( correlationID := vibelog.NewCLIVibeCorrelationID() logConnectionRetryFocusAttempt(controller.connection, controller.method, pid, reason, cause, correlationID) - restorer, focusErr := controller.deps.focusUnityProcess(ctx, pid) + focusContext, cancel := context.WithTimeout(ctx, unityprocess.FocusCommandTimeout) + defer cancel() + restorer, focusErr := controller.deps.focusUnityProcess(focusContext, pid) if focusErr == nil { controller.restoreFocus = restorer logConnectionRetryFocusSuccess(controller.connection, controller.method, pid, reason, cause, correlationID) diff --git a/cli/project-runner/internal/projectrunner/connection_retry_test.go b/cli/project-runner/internal/projectrunner/connection_retry_test.go index 21dfe142d..779d6423d 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_test.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_test.go @@ -18,6 +18,7 @@ import ( "github.com/hatayama/unity-cli-loop/common/clicore" "github.com/hatayama/unity-cli-loop/common/unityipc" + "github.com/hatayama/unity-cli-loop/common/unityprocess" ) // Verifies the default busy-stall focus threshold fires before the bounded busy retry window ends. @@ -33,6 +34,34 @@ func TestDefaultBusyFocusStallThresholdFitsWithinBusyRetryWindow(t *testing.T) { } } +// Verifies connection-retry focus rescue bounds the focus external command with a deadline. +func TestConnectionRetryFocusControllerBoundsFocusContext(t *testing.T) { + var receivedContext context.Context + deps := defaultConnectionRetryDeps() + deps.focusUnityProcess = func(ctx context.Context, pid int) (unityprocess.RestoreFocusFunc, error) { + receivedContext = ctx + return nil, nil + } + controller := newConnectionRetryFocusController( + unityipc.Connection{ProjectRoot: t.TempDir()}, + "get-logs", + deps, + ) + controller.tryFocusProcess(context.Background(), 123, focusReasonBusyStall, errors.New("busy")) + + if receivedContext == nil { + t.Fatal("expected focus attempt context") + } + deadline, hasDeadline := receivedContext.Deadline() + if !hasDeadline { + t.Fatal("focus attempt context should have a deadline") + } + remaining := time.Until(deadline) + if remaining < unityprocess.FocusCommandTimeout-time.Second || remaining > unityprocess.FocusCommandTimeout { + t.Fatalf("focus timeout mismatch: %s", remaining) + } +} + // Verifies transient IPC connection failures focus Unity once and restore focus before reporting server-not-responding. func TestSendWithTransientConnectionRetryReportsUnityServerNotResponding(t *testing.T) { deps := defaultConnectionRetryDeps() From 39633cedf8e9afcb4bf1ab6ed222fcb2f8bfb168 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 13 Jul 2026 14:10:58 +0900 Subject: [PATCH 3/4] Propagate Ctrl+C cancellation through CLI entrypoints Use signal.NotifyContext in dispatcher and project-runner main so in-flight work receives cancellation when the user interrupts the CLI. Co-authored-by: Cursor --- cli/dispatcher/cmd/dispatcher/main.go | 6 +++++- cli/dispatcher/shared-inputs-stamp.json | 2 +- cli/project-runner/cmd/project-runner/main.go | 6 +++++- cli/project-runner/shared-inputs-stamp.json | 2 +- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cli/dispatcher/cmd/dispatcher/main.go b/cli/dispatcher/cmd/dispatcher/main.go index 4fbcd5166..c834103ee 100644 --- a/cli/dispatcher/cmd/dispatcher/main.go +++ b/cli/dispatcher/cmd/dispatcher/main.go @@ -3,10 +3,14 @@ package main import ( "context" "os" + "os/signal" + "syscall" "github.com/hatayama/unity-cli-loop/dispatcher/internal/dispatcher" ) func main() { - os.Exit(dispatcher.RunDispatcher(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + os.Exit(dispatcher.RunDispatcher(ctx, os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 2a128a874..5fb6596a6 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "dff7284c186f1c36decc25ab36242de2860feeac" + "sharedInputsHash": "8b248a96bc8a1cbc8312c7f77da08d9ef8903680" } diff --git a/cli/project-runner/cmd/project-runner/main.go b/cli/project-runner/cmd/project-runner/main.go index 192f35c2a..3434cfa78 100644 --- a/cli/project-runner/cmd/project-runner/main.go +++ b/cli/project-runner/cmd/project-runner/main.go @@ -3,10 +3,14 @@ package main import ( "context" "os" + "os/signal" + "syscall" "github.com/hatayama/unity-cli-loop/project-runner/internal/projectrunner" ) func main() { - os.Exit(projectrunner.RunProjectLocal(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + os.Exit(projectrunner.RunProjectLocal(ctx, os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index cb9ee97da..e608f05e0 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "be6d79a7389bbf54e3c5020913f91955c15ec3f4" + "sharedInputsHash": "8c336555a69a3cf485147e5669ce8bb4d3fe2982" } From 23fba8b68162eb32dbbc8b6e8347f7d54e1a3285 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 13 Jul 2026 14:15:20 +0900 Subject: [PATCH 4/4] Restore default Ctrl+C handling after first cancellation After signal.NotifyContext cancels the root context, call stop again so a second interrupt exits immediately instead of being swallowed. Co-authored-by: Cursor --- cli/dispatcher/cmd/dispatcher/main.go | 1 + cli/project-runner/cmd/project-runner/main.go | 1 + 2 files changed, 2 insertions(+) diff --git a/cli/dispatcher/cmd/dispatcher/main.go b/cli/dispatcher/cmd/dispatcher/main.go index c834103ee..6ede83bb3 100644 --- a/cli/dispatcher/cmd/dispatcher/main.go +++ b/cli/dispatcher/cmd/dispatcher/main.go @@ -12,5 +12,6 @@ import ( func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + context.AfterFunc(ctx, stop) os.Exit(dispatcher.RunDispatcher(ctx, os.Args[1:], os.Stdout, os.Stderr)) } diff --git a/cli/project-runner/cmd/project-runner/main.go b/cli/project-runner/cmd/project-runner/main.go index 3434cfa78..087cdfedf 100644 --- a/cli/project-runner/cmd/project-runner/main.go +++ b/cli/project-runner/cmd/project-runner/main.go @@ -12,5 +12,6 @@ import ( func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + context.AfterFunc(ctx, stop) os.Exit(projectrunner.RunProjectLocal(ctx, os.Args[1:], os.Stdout, os.Stderr)) }