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/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/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) + } +} diff --git a/cli/dispatcher/cmd/dispatcher/main.go b/cli/dispatcher/cmd/dispatcher/main.go index 4fbcd5166..6ede83bb3 100644 --- a/cli/dispatcher/cmd/dispatcher/main.go +++ b/cli/dispatcher/cmd/dispatcher/main.go @@ -3,10 +3,15 @@ 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() + context.AfterFunc(ctx, 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..087cdfedf 100644 --- a/cli/project-runner/cmd/project-runner/main.go +++ b/cli/project-runner/cmd/project-runner/main.go @@ -3,10 +3,15 @@ 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() + context.AfterFunc(ctx, stop) + os.Exit(projectrunner.RunProjectLocal(ctx, os.Args[1:], os.Stdout, os.Stderr)) } 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() 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" }