From 6b0b57ea2ebf6bb999cb668723be09341e0afa62 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 4 Jul 2026 09:25:32 +0900 Subject: [PATCH] chore: Inject dispatcher runtime dependencies --- .../internal/dispatcher/dispatcher_process.go | 5 +- .../internal/dispatcher/dispatcher_test.go | 104 +++---- cli/dispatcher/internal/dispatcher/launch.go | 57 ++-- .../internal/dispatcher/launch_deps.go | 32 +++ .../internal/dispatcher/launch_focus_log.go | 4 +- .../launch_startup_timeout_error.go | 4 +- .../internal/dispatcher/launch_test.go | 255 ++++++------------ .../internal/dispatcher/run_dispatcher.go | 58 ++-- 8 files changed, 234 insertions(+), 285 deletions(-) create mode 100644 cli/dispatcher/internal/dispatcher/launch_deps.go diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_process.go b/cli/dispatcher/internal/dispatcher/dispatcher_process.go index ac43425143..a2f2728966 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_process.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_process.go @@ -34,7 +34,7 @@ func tryHandleDispatcherInfoRequest(args []string, stdout io.Writer) (bool, int) return false, 0 } -func tryHandlePreConnectionRequest( +func tryHandlePreConnectionRequestWithDeps( ctx context.Context, remainingArgs []string, command string, @@ -43,6 +43,7 @@ func tryHandlePreConnectionRequest( projectPath string, stdout io.Writer, stderr io.Writer, + deps dispatcherRunDeps, ) (bool, int) { if clicore.ShouldHandleCompletionRequest(remainingArgs) { completionTools := loadCompletionTools(startPath, projectPath) @@ -67,7 +68,7 @@ func tryHandlePreConnectionRequest( if handled, code := tryHandleUninstallRequest(ctx, remainingArgs, stdout, stderr); handled { return true, code } - if handled, code := tryHandleLaunchRequest(ctx, remainingArgs, startPath, projectPath, stdout, stderr); handled { + if handled, code := tryHandleLaunchRequestWithDeps(ctx, remainingArgs, startPath, projectPath, stdout, stderr, deps.launch); handled { return true, code } if handled, code := tryHandleSkillsRequest(remainingArgs, startPath, projectPath, stdout, stderr); handled { diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_test.go index dece87e02a..0513ad5ef9 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_test.go @@ -43,13 +43,10 @@ func TestRunDispatcherUsesProjectPinAndCachedRealCLI(t *testing.T) { t.Setenv(dispatcherDisableSelfUpdateEnvName, "1") t.Chdir(projectRoot) - previousRunner := dispatcherRunRealCLI - defer func() { - dispatcherRunRealCLI = previousRunner - }() + deps := defaultDispatcherRunDeps() var actualPath string var actualArgs []string - dispatcherRunRealCLI = func(ctx context.Context, realCLIPath string, args []string, stdout io.Writer, stderr io.Writer) int { + deps.runRealCLI = func(ctx context.Context, realCLIPath string, args []string, stdout io.Writer, stderr io.Writer) int { actualPath = realCLIPath actualArgs = append([]string{}, args...) return 7 @@ -57,7 +54,7 @@ func TestRunDispatcherUsesProjectPinAndCachedRealCLI(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer - code := RunDispatcher(context.Background(), []string{"compile", "--force-recompile"}, &stdout, &stderr) + code := runDispatcherWithDeps(context.Background(), []string{"compile", "--force-recompile"}, &stdout, &stderr, deps) if code != 7 { t.Fatalf("exit code mismatch: %d stderr=%s", code, stderr.String()) @@ -81,19 +78,16 @@ func TestRunDispatcherPreservesExplicitProjectPathForRealCLI(t *testing.T) { t.Setenv(dispatcherDisableSelfUpdateEnvName, "1") t.Chdir(t.TempDir()) - previousRunner := dispatcherRunRealCLI - defer func() { - dispatcherRunRealCLI = previousRunner - }() + deps := defaultDispatcherRunDeps() var actualArgs []string - dispatcherRunRealCLI = func(ctx context.Context, realCLIPath string, args []string, stdout io.Writer, stderr io.Writer) int { + deps.runRealCLI = func(ctx context.Context, realCLIPath string, args []string, stdout io.Writer, stderr io.Writer) int { actualArgs = append([]string{}, args...) return 0 } var stdout bytes.Buffer var stderr bytes.Buffer - code := RunDispatcher(context.Background(), []string{"compile", "--project-path", projectRoot}, &stdout, &stderr) + code := runDispatcherWithDeps(context.Background(), []string{"compile", "--project-path", projectRoot}, &stdout, &stderr, deps) if code != 0 { t.Fatalf("dispatcher failed: code=%d stderr=%s", code, stderr.String()) @@ -111,19 +105,16 @@ func TestRunDispatcherForwardsProjectScopedVersionToPinnedRunner(t *testing.T) { t.Setenv(dispatcherDisableSelfUpdateEnvName, "1") t.Chdir(t.TempDir()) - previousRunner := dispatcherRunRealCLI - defer func() { - dispatcherRunRealCLI = previousRunner - }() + deps := defaultDispatcherRunDeps() var actualArgs []string - dispatcherRunRealCLI = func(ctx context.Context, realCLIPath string, args []string, stdout io.Writer, stderr io.Writer) int { + deps.runRealCLI = func(ctx context.Context, realCLIPath string, args []string, stdout io.Writer, stderr io.Writer) int { actualArgs = append([]string{}, args...) return 0 } var stdout bytes.Buffer var stderr bytes.Buffer - code := RunDispatcher(context.Background(), []string{"--project-path", projectRoot, "--version"}, &stdout, &stderr) + code := runDispatcherWithDeps(context.Background(), []string{"--project-path", projectRoot, "--version"}, &stdout, &stderr, deps) if code != 0 { t.Fatalf("dispatcher failed: code=%d stderr=%s", code, stderr.String()) @@ -144,19 +135,16 @@ func TestRunDispatcherForwardsProjectScopedVersionJSONToPinnedRunner(t *testing. t.Setenv(dispatcherDisableSelfUpdateEnvName, "1") t.Chdir(t.TempDir()) - previousRunner := dispatcherRunRealCLI - defer func() { - dispatcherRunRealCLI = previousRunner - }() + deps := defaultDispatcherRunDeps() var actualArgs []string - dispatcherRunRealCLI = func(ctx context.Context, realCLIPath string, args []string, stdout io.Writer, stderr io.Writer) int { + deps.runRealCLI = func(ctx context.Context, realCLIPath string, args []string, stdout io.Writer, stderr io.Writer) int { actualArgs = append([]string{}, args...) return 0 } var stdout bytes.Buffer var stderr bytes.Buffer - code := RunDispatcher(context.Background(), []string{"--project-path", projectRoot, "--version", "--json"}, &stdout, &stderr) + code := runDispatcherWithDeps(context.Background(), []string{"--project-path", projectRoot, "--version", "--json"}, &stdout, &stderr, deps) if code != 0 { t.Fatalf("dispatcher failed: code=%d stderr=%s", code, stderr.String()) @@ -204,17 +192,14 @@ func TestRunDispatcherLaunchQuitDoesNotRequireProjectPin(t *testing.T) { projectRoot := createDispatcherUnityProject(t) t.Chdir(t.TempDir()) - previousFinder := findRunningUnityProcessForLaunch - findRunningUnityProcessForLaunch = func(context.Context, string) (*clicore.UnityProcess, error) { + deps := defaultDispatcherRunDeps() + deps.launch.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return nil, nil } - defer func() { - findRunningUnityProcessForLaunch = previousFinder - }() var stdout bytes.Buffer var stderr bytes.Buffer - code := RunDispatcher(context.Background(), []string{"launch", projectRoot, "--quit"}, &stdout, &stderr) + code := runDispatcherWithDeps(context.Background(), []string{"launch", projectRoot, "--quit"}, &stdout, &stderr, deps) if code != 0 { t.Fatalf("dispatcher launch failed: code=%d stderr=%s", code, stderr.String()) @@ -229,21 +214,19 @@ func TestRunDispatcherLaunchOptionsDoNotRequireProjectPin(t *testing.T) { projectRoot := createDispatcherUnityProject(t) t.Chdir(t.TempDir()) - previousFinder := findRunningUnityProcessForLaunch - findRunningUnityProcessForLaunch = func(context.Context, string) (*clicore.UnityProcess, error) { + deps := defaultDispatcherRunDeps() + deps.launch.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return nil, nil } - defer func() { - findRunningUnityProcessForLaunch = previousFinder - }() var stdout bytes.Buffer var stderr bytes.Buffer - code := RunDispatcher( + code := runDispatcherWithDeps( context.Background(), []string{"launch", "--editor-version", "6000.0.0f1", projectRoot, "--quit"}, &stdout, - &stderr) + &stderr, + deps) if code != 0 { t.Fatalf("dispatcher launch failed: code=%d stderr=%s", code, stderr.String()) @@ -303,21 +286,19 @@ func TestEnforceDispatcherFreshnessMarksFailedOptionalUpdateChecked(t *testing.T cacheRoot := t.TempDir() t.Setenv(nativepath.CacheDirEnvName, cacheRoot) - previousRunner := dispatcherRunUpdate - defer func() { - dispatcherRunUpdate = previousRunner - }() + deps := defaultDispatcherRunDeps() runnerCalls := 0 - dispatcherRunUpdate = func(context.Context) error { + deps.runUpdate = func(context.Context) error { runnerCalls++ return errors.New("network unavailable") } var stderr bytes.Buffer - handled, code := enforceDispatcherFreshness( + handled, code := enforceDispatcherFreshnessWithDeps( context.Background(), dispatcherPin{MinimumDispatcherVersion: dispatcherVersion}, - &stderr) + &stderr, + deps) if handled || code != 0 { t.Fatalf("freshness result mismatch: handled=%t code=%d", handled, code) @@ -331,10 +312,11 @@ func TestEnforceDispatcherFreshnessMarksFailedOptionalUpdateChecked(t *testing.T } stderr.Reset() - handled, code = enforceDispatcherFreshness( + handled, code = enforceDispatcherFreshnessWithDeps( context.Background(), dispatcherPin{MinimumDispatcherVersion: dispatcherVersion}, - &stderr) + &stderr, + deps) if handled || code != 0 { t.Fatalf("second freshness result mismatch: handled=%t code=%d", handled, code) @@ -350,14 +332,15 @@ func TestEnforceDispatcherFreshnessMarksFailedOptionalUpdateChecked(t *testing.T func TestEnforceDispatcherFreshnessReportsOptionalUpdateVersionChange(t *testing.T) { // Verifies optional dispatcher self-updates tell users which launcher version will run next. t.Setenv(nativepath.CacheDirEnvName, t.TempDir()) - restoreDispatcherUpdateHooks := stubDispatcherUpdateHooks(t, "9.9.9") + deps, restoreDispatcherUpdateHooks := stubDispatcherUpdateHooks(t, "9.9.9") defer restoreDispatcherUpdateHooks() var stderr bytes.Buffer - handled, code := enforceDispatcherFreshness( + handled, code := enforceDispatcherFreshnessWithDeps( context.Background(), dispatcherPin{MinimumDispatcherVersion: dispatcherVersion}, - &stderr) + &stderr, + deps) if handled || code != 0 { t.Fatalf("freshness result mismatch: handled=%t code=%d", handled, code) @@ -371,14 +354,15 @@ func TestEnforceDispatcherFreshnessReportsOptionalUpdateVersionChange(t *testing func TestEnforceDispatcherFreshnessSkipsOptionalUpdateMessageWhenVersionDidNotChange(t *testing.T) { // Verifies no-op optional dispatcher self-updates do not add noise before the real command output. t.Setenv(nativepath.CacheDirEnvName, t.TempDir()) - restoreDispatcherUpdateHooks := stubDispatcherUpdateHooks(t, dispatcherVersion) + deps, restoreDispatcherUpdateHooks := stubDispatcherUpdateHooks(t, dispatcherVersion) defer restoreDispatcherUpdateHooks() var stderr bytes.Buffer - handled, code := enforceDispatcherFreshness( + handled, code := enforceDispatcherFreshnessWithDeps( context.Background(), dispatcherPin{MinimumDispatcherVersion: dispatcherVersion}, - &stderr) + &stderr, + deps) if handled || code != 0 { t.Fatalf("freshness result mismatch: handled=%t code=%d", handled, code) @@ -391,14 +375,15 @@ func TestEnforceDispatcherFreshnessSkipsOptionalUpdateMessageWhenVersionDidNotCh func TestEnforceDispatcherFreshnessReportsRequiredUpdateVersionChange(t *testing.T) { // Verifies required dispatcher self-updates include the version change before asking for a retry. t.Setenv(nativepath.CacheDirEnvName, t.TempDir()) - restoreDispatcherUpdateHooks := stubDispatcherUpdateHooks(t, "999.0.0") + deps, restoreDispatcherUpdateHooks := stubDispatcherUpdateHooks(t, "999.0.0") defer restoreDispatcherUpdateHooks() var stderr bytes.Buffer - handled, code := enforceDispatcherFreshness( + handled, code := enforceDispatcherFreshnessWithDeps( context.Background(), dispatcherPin{MinimumDispatcherVersion: "999.0.0"}, - &stderr) + &stderr, + deps) if !handled || code != 1 { t.Fatalf("freshness result mismatch: handled=%t code=%d", handled, code) @@ -412,18 +397,17 @@ func TestEnforceDispatcherFreshnessReportsRequiredUpdateVersionChange(t *testing } } -func stubDispatcherUpdateHooks(t *testing.T, updatedVersion string) func() { +func stubDispatcherUpdateHooks(t *testing.T, updatedVersion string) (dispatcherRunDeps, func()) { t.Helper() - previousRunner := dispatcherRunUpdate previousReader := dispatcherReadInstalledVersion - dispatcherRunUpdate = func(context.Context) error { + deps := defaultDispatcherRunDeps() + deps.runUpdate = func(context.Context) error { return nil } dispatcherReadInstalledVersion = func(context.Context) (string, error) { return updatedVersion, nil } - return func() { - dispatcherRunUpdate = previousRunner + return deps, func() { dispatcherReadInstalledVersion = previousReader } } diff --git a/cli/dispatcher/internal/dispatcher/launch.go b/cli/dispatcher/internal/dispatcher/launch.go index 2902ee4bd3..c69ff1a314 100644 --- a/cli/dispatcher/internal/dispatcher/launch.go +++ b/cli/dispatcher/internal/dispatcher/launch.go @@ -29,17 +29,6 @@ const ( unityLockfileName = "UnityLockfile" ) -var ( - findRunningUnityProcessForLaunch = clicore.FindRunningUnityProcess - focusUnityProcessForLaunch = clicore.FocusUnityProcess - killUnityProcessForLaunch = killUnityProcess - resolveUnityExecutablePathForLaunch = resolveUnityExecutablePath - waitForUnityProcessExitForLaunch = waitForUnityProcessExit - waitForUnityStartupMarkerForLaunch = waitForUnityStartupMarkerOrTimeout - waitForToolReadinessForLaunch = clicore.WaitForToolReadinessWithTimeout - probeProjectIpcForLaunchFallback = clicore.ProbeToolReadinessSequence -) - var editorVersionPattern = regexp.MustCompile(`(?m)^m_EditorVersion:\s*(.+)$`) type launchOptions struct { @@ -52,13 +41,14 @@ type launchOptions struct { maxDepth int } -func tryHandleLaunchRequest( +func tryHandleLaunchRequestWithDeps( ctx context.Context, args []string, startPath string, globalProjectPath string, stdout io.Writer, stderr io.Writer, + deps launchDeps, ) (bool, int) { if len(args) == 0 || args[0] != clicore.LaunchCommandName { return false, 0 @@ -74,7 +64,7 @@ func tryHandleLaunchRequest( return true, 1 } - exitCode := runLaunch(ctx, options, startPath, stdout, stderr) + exitCode := runLaunchWithDeps(ctx, options, startPath, stdout, stderr, deps) return true, exitCode } @@ -96,6 +86,10 @@ func parseLaunchOptions(args []string, globalProjectPath string) (launchOptions, } func runLaunch(ctx context.Context, options launchOptions, startPath string, stdout io.Writer, stderr io.Writer) int { + return runLaunchWithDeps(ctx, options, startPath, stdout, stderr, defaultLaunchDeps()) +} + +func runLaunchWithDeps(ctx context.Context, options launchOptions, startPath string, stdout io.Writer, stderr io.Writer, deps launchDeps) int { writeLaunchProjectSearch(stdout, options, startPath) projectRoot, err := resolveLaunchProjectRoot(startPath, options) @@ -108,13 +102,13 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std return 1 } - runningProcess, handled, code := findLaunchRunningProcess(ctx, options, projectRoot, stdout, stderr) + runningProcess, handled, code := findLaunchRunningProcess(ctx, options, projectRoot, stdout, stderr, deps) if handled { return code } if runningProcess != nil { - if handled, code := handleExistingLaunchProcess(ctx, options, projectRoot, runningProcess, stdout, stderr); handled { + if handled, code := handleExistingLaunchProcess(ctx, options, projectRoot, runningProcess, stdout, stderr, deps); handled { return code } } @@ -123,7 +117,7 @@ func runLaunch(ctx context.Context, options launchOptions, startPath string, std return writeLaunchQuitResponse(stdout, stderr, projectRoot, nil, launchNoProcessMessage) } - return startUnityAndWaitForReadiness(ctx, options, projectRoot, runningProcess, stdout, stderr) + return startUnityAndWaitForReadiness(ctx, options, projectRoot, runningProcess, stdout, stderr, deps) } func writeLaunchProjectSearch(stdout io.Writer, options launchOptions, startPath string) { @@ -154,15 +148,16 @@ func findLaunchRunningProcess( projectRoot string, stdout io.Writer, stderr io.Writer, + deps launchDeps, ) (*clicore.UnityProcess, bool, int) { - runningProcess, err := findRunningUnityProcessForLaunch(ctx, projectRoot) + runningProcess, err := deps.findRunningUnityProcess(ctx, projectRoot) if err == nil { return runningProcess, false, 0 } // Sandboxes can block the process scan (e.g. /bin/ps). A responding project IPC // proves Unity is running, so plain launch must not fail on the scan alone. // Restart and quit still fail because they need a process id to kill. - if !options.restart && !options.quit && probeProjectIpcForLaunchFallback(ctx, projectRoot) == nil { + if !options.restart && !options.quit && deps.probeProjectIpcFallback(ctx, projectRoot) == nil { return nil, true, writeDetectionFallbackLaunchReadyResponse(stdout, stderr, projectRoot, err) } clicore.WriteClassifiedError(stderr, err, clicore.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) @@ -176,19 +171,20 @@ func handleExistingLaunchProcess( runningProcess *clicore.UnityProcess, stdout io.Writer, stderr io.Writer, + deps launchDeps, ) (bool, int) { if !options.restart && !options.quit { if options.editorVersion != "" { clicore.WriteClassifiedError(stderr, launchEditorVersionRequiresRestartError(options.editorVersion), clicore.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) return true, 1 } - return true, waitForExistingLaunchReadiness(ctx, projectRoot, runningProcess.Pid, stdout, stderr) + return true, waitForExistingLaunchReadiness(ctx, projectRoot, runningProcess.Pid, stdout, stderr, deps) } - if err := killUnityProcessForLaunch(runningProcess.Pid); err != nil { + if err := deps.killUnityProcess(runningProcess.Pid); err != nil { clicore.WriteClassifiedError(stderr, err, clicore.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) return true, 1 } - if err := waitForUnityProcessExitForLaunch(ctx, projectRoot, runningProcess.Pid, launchProcessExitPoll, launchProcessExitTimeout); err != nil { + if err := deps.waitForUnityProcessExit(ctx, projectRoot, runningProcess.Pid, launchProcessExitPoll, launchProcessExitTimeout); err != nil { clicore.WriteClassifiedError(stderr, err, clicore.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) return true, 1 } @@ -210,12 +206,12 @@ func launchEditorVersionRequiresRestartError(editorVersion string) error { } } -func waitForExistingLaunchReadiness(ctx context.Context, projectRoot string, pid int, stdout io.Writer, stderr io.Writer) int { - logLaunchExistingFocus(ctx, projectRoot, pid) +func waitForExistingLaunchReadiness(ctx context.Context, projectRoot string, pid int, stdout io.Writer, stderr io.Writer, deps launchDeps) int { + logLaunchExistingFocusWithDeps(ctx, projectRoot, pid, deps) spinner := clicore.NewLaunchSpinner(stdout, stderr) defer spinner.Stop() writeLaunchReadinessWait(stdout, spinner) - if err := waitForLaunchReadiness(ctx, projectRoot); err != nil { + if err := waitForLaunchReadinessWithDeps(ctx, projectRoot, deps); err != nil { clicore.WriteClassifiedError(stderr, err, clicore.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) return 1 } @@ -230,6 +226,7 @@ func startUnityAndWaitForReadiness( runningProcess *clicore.UnityProcess, stdout io.Writer, stderr io.Writer, + deps launchDeps, ) int { removedStaleTemp, err := cleanStaleUnityTemp(projectRoot) if err != nil { @@ -250,7 +247,7 @@ func startUnityAndWaitForReadiness( return 1 } - unityPath, err := resolveUnityExecutablePathForLaunch(unityVersion) + unityPath, err := deps.resolveUnityExecutablePath(unityVersion) if err != nil { clicore.WriteClassifiedError(stderr, err, clicore.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) return 1 @@ -274,12 +271,12 @@ func startUnityAndWaitForReadiness( clicore.WriteClassifiedError(stderr, err, clicore.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) return 1 } - if err := waitForUnityStartupMarkerForLaunch(ctx, unityLockfilePath(projectRoot), launchLockfilePoll, launchLockfileTimeout); err != nil { + if err := deps.waitForUnityStartupMarker(ctx, unityLockfilePath(projectRoot), launchLockfilePoll, launchLockfileTimeout); err != nil { clicore.WriteClassifiedError(stderr, err, clicore.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) return 1 } writeLaunchReadinessWait(stdout, spinner) - if err := waitForLaunchReadiness(ctx, projectRoot); err != nil { + if err := waitForLaunchReadinessWithDeps(ctx, projectRoot, deps); err != nil { clicore.WriteClassifiedError(stderr, err, clicore.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) return 1 } @@ -320,6 +317,10 @@ func cleanStaleUnityTemp(projectRoot string) (bool, error) { } func waitForUnityProcessExit(ctx context.Context, projectRoot string, pid int, pollInterval time.Duration, timeout time.Duration) error { + return waitForUnityProcessExitWithDeps(ctx, projectRoot, pid, pollInterval, timeout, defaultLaunchDeps()) +} + +func waitForUnityProcessExitWithDeps(ctx context.Context, projectRoot string, pid int, pollInterval time.Duration, timeout time.Duration, deps launchDeps) error { timeoutContext, cancel := context.WithTimeout(ctx, timeout) defer cancel() timeoutError := func() error { @@ -337,7 +338,7 @@ func waitForUnityProcessExit(ctx context.Context, projectRoot string, pid int, p defer ticker.Stop() for { - runningProcess, err := findRunningUnityProcessForLaunch(timeoutContext, projectRoot) + runningProcess, err := deps.findRunningUnityProcess(timeoutContext, projectRoot) if err != nil { if timeoutContext.Err() != nil { return timeoutError() diff --git a/cli/dispatcher/internal/dispatcher/launch_deps.go b/cli/dispatcher/internal/dispatcher/launch_deps.go new file mode 100644 index 0000000000..e9c5d6dd81 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/launch_deps.go @@ -0,0 +1,32 @@ +package dispatcher + +import ( + "context" + "time" + + "github.com/hatayama/unity-cli-loop/common/clicore" +) + +type launchDeps struct { + findRunningUnityProcess func(context.Context, string) (*clicore.UnityProcess, error) + focusUnityProcess func(context.Context, int) error + killUnityProcess func(int) error + resolveUnityExecutablePath func(string) (string, error) + waitForUnityProcessExit func(context.Context, string, int, time.Duration, time.Duration) error + waitForUnityStartupMarker func(context.Context, string, time.Duration, time.Duration) error + waitForToolReadiness func(context.Context, string, time.Duration) error + probeProjectIpcFallback func(context.Context, string) error +} + +func defaultLaunchDeps() launchDeps { + return launchDeps{ + findRunningUnityProcess: clicore.FindRunningUnityProcess, + focusUnityProcess: clicore.FocusUnityProcess, + killUnityProcess: killUnityProcess, + resolveUnityExecutablePath: resolveUnityExecutablePath, + waitForUnityProcessExit: waitForUnityProcessExit, + waitForUnityStartupMarker: waitForUnityStartupMarkerOrTimeout, + waitForToolReadiness: clicore.WaitForToolReadinessWithTimeout, + probeProjectIpcFallback: clicore.ProbeToolReadinessSequence, + } +} diff --git a/cli/dispatcher/internal/dispatcher/launch_focus_log.go b/cli/dispatcher/internal/dispatcher/launch_focus_log.go index 8d51df3e28..4fe97ce9bc 100644 --- a/cli/dispatcher/internal/dispatcher/launch_focus_log.go +++ b/cli/dispatcher/internal/dispatcher/launch_focus_log.go @@ -6,10 +6,10 @@ import ( "github.com/hatayama/unity-cli-loop/common/clicore" ) -func logLaunchExistingFocus(ctx context.Context, projectRoot string, pid int) { +func logLaunchExistingFocusWithDeps(ctx context.Context, projectRoot string, pid int, deps launchDeps) { correlationID := clicore.NewCLIVibeCorrelationID() logLaunchExistingFocusAttempt(projectRoot, pid, correlationID) - if err := focusUnityProcessForLaunch(ctx, pid); err != nil { + if err := deps.focusUnityProcess(ctx, pid); err != nil { logLaunchExistingFocusFailure(projectRoot, pid, err, correlationID) return } diff --git a/cli/dispatcher/internal/dispatcher/launch_startup_timeout_error.go b/cli/dispatcher/internal/dispatcher/launch_startup_timeout_error.go index a675ee9bb1..640baf9f1b 100644 --- a/cli/dispatcher/internal/dispatcher/launch_startup_timeout_error.go +++ b/cli/dispatcher/internal/dispatcher/launch_startup_timeout_error.go @@ -24,8 +24,8 @@ func (err launchStartupTimeoutError) Unwrap() error { return err.cause } -func waitForLaunchReadiness(ctx context.Context, projectRoot string) error { - err := waitForToolReadinessForLaunch(ctx, projectRoot, launchReadinessTimeout) +func waitForLaunchReadinessWithDeps(ctx context.Context, projectRoot string, deps launchDeps) error { + err := deps.waitForToolReadiness(ctx, projectRoot, launchReadinessTimeout) if err == nil { return nil } diff --git a/cli/dispatcher/internal/dispatcher/launch_test.go b/cli/dispatcher/internal/dispatcher/launch_test.go index e639fbfef7..797da96df7 100644 --- a/cli/dispatcher/internal/dispatcher/launch_test.go +++ b/cli/dispatcher/internal/dispatcher/launch_test.go @@ -257,39 +257,31 @@ func TestRunLaunchQuitDoesNotLaunchWhenUnityIsNotRunning(t *testing.T) { func TestRunLaunchWritesReadyResponseAfterToolReadiness(t *testing.T) { // Verifies launch reports an explicit ready payload after Unity accepts tool requests. - originalFinder := findRunningUnityProcessForLaunch - originalResolver := resolveUnityExecutablePathForLaunch - originalStartupMarkerWait := waitForUnityStartupMarkerForLaunch - originalReadinessWait := waitForToolReadinessForLaunch - findRunningUnityProcessForLaunch = func(context.Context, string) (*clicore.UnityProcess, error) { + deps := defaultLaunchDeps() + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return nil, nil } - resolveUnityExecutablePathForLaunch = func(string) (string, error) { + deps.resolveUnityExecutablePath = func(string) (string, error) { return "/usr/bin/true", nil } - waitForUnityStartupMarkerForLaunch = func(context.Context, string, time.Duration, time.Duration) error { + deps.waitForUnityStartupMarker = func(context.Context, string, time.Duration, time.Duration) error { return nil } - waitForToolReadinessForLaunch = func(context.Context, string, time.Duration) error { + deps.waitForToolReadiness = func(context.Context, string, time.Duration) error { return nil } - t.Cleanup(func() { - findRunningUnityProcessForLaunch = originalFinder - resolveUnityExecutablePathForLaunch = originalResolver - waitForUnityStartupMarkerForLaunch = originalStartupMarkerWait - waitForToolReadinessForLaunch = originalReadinessWait - }) projectRoot := createLaunchTestProject(t) var stdout bytes.Buffer var stderr bytes.Buffer - code := runLaunch( + code := runLaunchWithDeps( context.Background(), launchOptions{projectPath: projectRoot, editorVersion: "6000.0.0f1"}, projectRoot, &stdout, &stderr, + deps, ) if code != 0 { @@ -312,18 +304,15 @@ func TestRunLaunchWritesReadyResponseAfterToolReadiness(t *testing.T) { func TestWaitForLaunchReadinessUsesLaunchTimeout(t *testing.T) { // Verifies launch gets a longer startup window without changing shared readiness defaults. - originalReadinessWait := waitForToolReadinessForLaunch + deps := defaultLaunchDeps() var capturedTimeout time.Duration - waitForToolReadinessForLaunch = func(ctx context.Context, projectRoot string, timeout time.Duration) error { + deps.waitForToolReadiness = func(ctx context.Context, projectRoot string, timeout time.Duration) error { capturedTimeout = timeout return nil } - t.Cleanup(func() { - waitForToolReadinessForLaunch = originalReadinessWait - }) - if err := waitForLaunchReadiness(context.Background(), t.TempDir()); err != nil { - t.Fatalf("waitForLaunchReadiness failed: %v", err) + if err := waitForLaunchReadinessWithDeps(context.Background(), t.TempDir(), deps); err != nil { + t.Fatalf("waitForLaunchReadinessWithDeps failed: %v", err) } if capturedTimeout != launchReadinessTimeout { t.Fatalf("launch readiness timeout mismatch: %s", capturedTimeout) @@ -332,19 +321,16 @@ func TestWaitForLaunchReadinessUsesLaunchTimeout(t *testing.T) { func TestWaitForLaunchReadinessWrapsStartupTimeout(t *testing.T) { // Verifies launch timeout errors receive the launch-specific startup classification. - originalReadinessWait := waitForToolReadinessForLaunch - waitForToolReadinessForLaunch = func(ctx context.Context, projectRoot string, timeout time.Duration) error { + deps := defaultLaunchDeps() + deps.waitForToolReadiness = func(ctx context.Context, projectRoot string, timeout time.Duration) error { return clicore.UnityServerNotRespondingError{ ProjectRoot: projectRoot, Endpoint: "/tmp/uloop/UnityCliLoop-sample.sock", Cause: errors.New("timed out waiting for Unity tool readiness"), } } - t.Cleanup(func() { - waitForToolReadinessForLaunch = originalReadinessWait - }) - err := waitForLaunchReadiness(context.Background(), t.TempDir()) + err := waitForLaunchReadinessWithDeps(context.Background(), t.TempDir(), deps) var startupErr launchStartupTimeoutError if !errors.As(err, &startupErr) { @@ -354,19 +340,16 @@ func TestWaitForLaunchReadinessWrapsStartupTimeout(t *testing.T) { func TestWaitForLaunchReadinessWrapsInternalProbeDeadline(t *testing.T) { // Verifies probe deadlines are classified as launch startup timeouts while the parent context is active. - originalReadinessWait := waitForToolReadinessForLaunch - waitForToolReadinessForLaunch = func(ctx context.Context, projectRoot string, timeout time.Duration) error { + deps := defaultLaunchDeps() + deps.waitForToolReadiness = func(ctx context.Context, projectRoot string, timeout time.Duration) error { return clicore.UnityServerNotRespondingError{ ProjectRoot: projectRoot, Endpoint: "/tmp/uloop/UnityCliLoop-sample.sock", Cause: fmt.Errorf("probe deadline: %w", context.DeadlineExceeded), } } - t.Cleanup(func() { - waitForToolReadinessForLaunch = originalReadinessWait - }) - err := waitForLaunchReadiness(context.Background(), t.TempDir()) + err := waitForLaunchReadinessWithDeps(context.Background(), t.TempDir(), deps) var startupErr launchStartupTimeoutError if !errors.As(err, &startupErr) { @@ -379,19 +362,16 @@ func TestWaitForLaunchReadinessWrapsInternalProbeDeadline(t *testing.T) { func TestWaitForLaunchReadinessPreservesNoProcessReachability(t *testing.T) { // Verifies a launch whose Editor exited before readiness does not report that Unity is running. - originalReadinessWait := waitForToolReadinessForLaunch - waitForToolReadinessForLaunch = func(ctx context.Context, projectRoot string, timeout time.Duration) error { + deps := defaultLaunchDeps() + deps.waitForToolReadiness = func(ctx context.Context, projectRoot string, timeout time.Duration) error { return fmt.Errorf("timed out waiting for Unity tool readiness: %w", &unityipc.ConnectionAttemptError{ ProjectRoot: projectRoot, Endpoint: "/tmp/uloop/UnityCliLoop-sample.sock", Cause: errors.New("connect failed"), }) } - t.Cleanup(func() { - waitForToolReadinessForLaunch = originalReadinessWait - }) - err := waitForLaunchReadiness(context.Background(), t.TempDir()) + err := waitForLaunchReadinessWithDeps(context.Background(), t.TempDir(), deps) var startupErr launchStartupTimeoutError if errors.As(err, &startupErr) { @@ -408,17 +388,14 @@ func TestWaitForLaunchReadinessPreservesNoProcessReachability(t *testing.T) { func TestWaitForLaunchReadinessPreservesParentCancellation(t *testing.T) { // Verifies caller cancellation is not converted into a launch startup timeout. - originalReadinessWait := waitForToolReadinessForLaunch - waitForToolReadinessForLaunch = func(ctx context.Context, projectRoot string, timeout time.Duration) error { + deps := defaultLaunchDeps() + deps.waitForToolReadiness = func(ctx context.Context, projectRoot string, timeout time.Duration) error { return ctx.Err() } - t.Cleanup(func() { - waitForToolReadinessForLaunch = originalReadinessWait - }) ctx, cancel := context.WithCancel(context.Background()) cancel() - err := waitForLaunchReadiness(ctx, t.TempDir()) + err := waitForLaunchReadinessWithDeps(ctx, t.TempDir(), deps) if !errors.Is(err, context.Canceled) { t.Fatalf("expected parent cancellation, got %v", err) @@ -427,36 +404,30 @@ func TestWaitForLaunchReadinessPreservesParentCancellation(t *testing.T) { func TestRunLaunchWritesStructuredResponseForExistingUnityProcess(t *testing.T) { // Verifies launch reports machine-readable readiness when Unity was already running. - originalFinder := findRunningUnityProcessForLaunch - originalFocus := focusUnityProcessForLaunch - originalReadinessWait := waitForToolReadinessForLaunch + deps := defaultLaunchDeps() readinessChecked := false - findRunningUnityProcessForLaunch = func(context.Context, string) (*clicore.UnityProcess, error) { + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return &clicore.UnityProcess{Pid: 111}, nil } - focusUnityProcessForLaunch = func(context.Context, int) error { + deps.focusUnityProcess = func(context.Context, int) error { return nil } - waitForToolReadinessForLaunch = func(context.Context, string, time.Duration) error { + deps.waitForToolReadiness = func(context.Context, string, time.Duration) error { readinessChecked = true return nil } - t.Cleanup(func() { - findRunningUnityProcessForLaunch = originalFinder - focusUnityProcessForLaunch = originalFocus - waitForToolReadinessForLaunch = originalReadinessWait - }) projectRoot := createLaunchTestProject(t) var stdout bytes.Buffer var stderr bytes.Buffer - code := runLaunch( + code := runLaunchWithDeps( context.Background(), launchOptions{projectPath: projectRoot}, projectRoot, &stdout, &stderr, + deps, ) if code != 0 { @@ -482,31 +453,27 @@ func TestRunLaunchWritesStructuredResponseForExistingUnityProcess(t *testing.T) func TestRunLaunchRequiresRestartForEditorVersionWithExistingUnityProcess(t *testing.T) { // Verifies --editor-version cannot silently reuse an already running Editor process. - originalFinder := findRunningUnityProcessForLaunch - originalReadinessWait := waitForToolReadinessForLaunch + deps := defaultLaunchDeps() readinessChecked := false - findRunningUnityProcessForLaunch = func(context.Context, string) (*clicore.UnityProcess, error) { + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return &clicore.UnityProcess{Pid: 222}, nil } - waitForToolReadinessForLaunch = func(context.Context, string, time.Duration) error { + deps.waitForToolReadiness = func(context.Context, string, time.Duration) error { readinessChecked = true return nil } - t.Cleanup(func() { - findRunningUnityProcessForLaunch = originalFinder - waitForToolReadinessForLaunch = originalReadinessWait - }) projectRoot := createLaunchTestProject(t) var stdout bytes.Buffer var stderr bytes.Buffer - code := runLaunch( + code := runLaunchWithDeps( context.Background(), launchOptions{projectPath: projectRoot, editorVersion: "6000.0.0f1"}, projectRoot, &stdout, &stderr, + deps, ) if code != 1 { @@ -522,53 +489,41 @@ func TestRunLaunchRequiresRestartForEditorVersionWithExistingUnityProcess(t *tes func TestRunLaunchRestartWritesProcessTransitionResponse(t *testing.T) { // Verifies restart reports both the stopped process and the newly launched process. - originalFinder := findRunningUnityProcessForLaunch - originalKiller := killUnityProcessForLaunch - originalResolver := resolveUnityExecutablePathForLaunch - originalExitWait := waitForUnityProcessExitForLaunch - originalStartupMarkerWait := waitForUnityStartupMarkerForLaunch - originalReadinessWait := waitForToolReadinessForLaunch + deps := defaultLaunchDeps() killedPid := 0 waitedPid := 0 - findRunningUnityProcessForLaunch = func(context.Context, string) (*clicore.UnityProcess, error) { + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return &clicore.UnityProcess{Pid: 222}, nil } - killUnityProcessForLaunch = func(pid int) error { + deps.killUnityProcess = func(pid int) error { killedPid = pid return nil } - waitForUnityProcessExitForLaunch = func(ctx context.Context, projectRoot string, pid int, pollInterval time.Duration, timeout time.Duration) error { + deps.waitForUnityProcessExit = func(ctx context.Context, projectRoot string, pid int, pollInterval time.Duration, timeout time.Duration) error { waitedPid = pid return nil } - resolveUnityExecutablePathForLaunch = func(string) (string, error) { + deps.resolveUnityExecutablePath = func(string) (string, error) { return "/usr/bin/true", nil } - waitForUnityStartupMarkerForLaunch = func(context.Context, string, time.Duration, time.Duration) error { + deps.waitForUnityStartupMarker = func(context.Context, string, time.Duration, time.Duration) error { return nil } - waitForToolReadinessForLaunch = func(context.Context, string, time.Duration) error { + deps.waitForToolReadiness = func(context.Context, string, time.Duration) error { return nil } - t.Cleanup(func() { - findRunningUnityProcessForLaunch = originalFinder - killUnityProcessForLaunch = originalKiller - resolveUnityExecutablePathForLaunch = originalResolver - waitForUnityProcessExitForLaunch = originalExitWait - waitForUnityStartupMarkerForLaunch = originalStartupMarkerWait - waitForToolReadinessForLaunch = originalReadinessWait - }) projectRoot := createLaunchTestProject(t) var stdout bytes.Buffer var stderr bytes.Buffer - code := runLaunch( + code := runLaunchWithDeps( context.Background(), launchOptions{projectPath: projectRoot, restart: true, editorVersion: "6000.0.0f1"}, projectRoot, &stdout, &stderr, + deps, ) if code != 0 { @@ -597,36 +552,30 @@ func TestRunLaunchRestartWritesProcessTransitionResponse(t *testing.T) { func TestRunLaunchQuitWaitsForKilledUnityProcess(t *testing.T) { // Verifies quit does not report success before the killed Unity process disappears. - originalFinder := findRunningUnityProcessForLaunch - originalKiller := killUnityProcessForLaunch - originalExitWait := waitForUnityProcessExitForLaunch + deps := defaultLaunchDeps() waitedPid := 0 - findRunningUnityProcessForLaunch = func(context.Context, string) (*clicore.UnityProcess, error) { + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return &clicore.UnityProcess{Pid: 333}, nil } - killUnityProcessForLaunch = func(pid int) error { + deps.killUnityProcess = func(pid int) error { return nil } - waitForUnityProcessExitForLaunch = func(ctx context.Context, projectRoot string, pid int, pollInterval time.Duration, timeout time.Duration) error { + deps.waitForUnityProcessExit = func(ctx context.Context, projectRoot string, pid int, pollInterval time.Duration, timeout time.Duration) error { waitedPid = pid return nil } - t.Cleanup(func() { - findRunningUnityProcessForLaunch = originalFinder - killUnityProcessForLaunch = originalKiller - waitForUnityProcessExitForLaunch = originalExitWait - }) projectRoot := createLaunchTestProject(t) var stdout bytes.Buffer var stderr bytes.Buffer - code := runLaunch( + code := runLaunchWithDeps( context.Background(), launchOptions{projectPath: projectRoot, quit: true}, projectRoot, &stdout, &stderr, + deps, ) if code != 0 { @@ -646,41 +595,33 @@ func TestRunLaunchQuitWaitsForKilledUnityProcess(t *testing.T) { func TestRunLaunchRestartReportsProcessExitWaitFailure(t *testing.T) { // Verifies restart stops before Temp cleanup when the killed Unity process still holds files. - originalFinder := findRunningUnityProcessForLaunch - originalKiller := killUnityProcessForLaunch - originalExitWait := waitForUnityProcessExitForLaunch - originalResolver := resolveUnityExecutablePathForLaunch + deps := defaultLaunchDeps() resolverCalled := false - findRunningUnityProcessForLaunch = func(context.Context, string) (*clicore.UnityProcess, error) { + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return &clicore.UnityProcess{Pid: 444}, nil } - killUnityProcessForLaunch = func(pid int) error { + deps.killUnityProcess = func(pid int) error { return nil } - waitForUnityProcessExitForLaunch = func(ctx context.Context, projectRoot string, pid int, pollInterval time.Duration, timeout time.Duration) error { + deps.waitForUnityProcessExit = func(ctx context.Context, projectRoot string, pid int, pollInterval time.Duration, timeout time.Duration) error { return errors.New("still exiting") } - resolveUnityExecutablePathForLaunch = func(string) (string, error) { + deps.resolveUnityExecutablePath = func(string) (string, error) { resolverCalled = true return "/usr/bin/true", nil } - t.Cleanup(func() { - findRunningUnityProcessForLaunch = originalFinder - killUnityProcessForLaunch = originalKiller - waitForUnityProcessExitForLaunch = originalExitWait - resolveUnityExecutablePathForLaunch = originalResolver - }) projectRoot := createLaunchTestProject(t) var stdout bytes.Buffer var stderr bytes.Buffer - code := runLaunch( + code := runLaunchWithDeps( context.Background(), launchOptions{projectPath: projectRoot, restart: true}, projectRoot, &stdout, &stderr, + deps, ) if code != 1 { @@ -698,34 +639,28 @@ func TestRunLaunchRestartReportsProcessExitWaitFailure(t *testing.T) { func TestRunLaunchWritesExistingFocusSuccessVibeLog(t *testing.T) { enableCliVibeLog(t) - originalFinder := findRunningUnityProcessForLaunch - originalFocus := focusUnityProcessForLaunch - originalReadinessWait := waitForToolReadinessForLaunch - findRunningUnityProcessForLaunch = func(context.Context, string) (*clicore.UnityProcess, error) { + deps := defaultLaunchDeps() + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return &clicore.UnityProcess{Pid: 111}, nil } - focusUnityProcessForLaunch = func(context.Context, int) error { + deps.focusUnityProcess = func(context.Context, int) error { return nil } - waitForToolReadinessForLaunch = func(context.Context, string, time.Duration) error { + deps.waitForToolReadiness = func(context.Context, string, time.Duration) error { return nil } - t.Cleanup(func() { - findRunningUnityProcessForLaunch = originalFinder - focusUnityProcessForLaunch = originalFocus - waitForToolReadinessForLaunch = originalReadinessWait - }) projectRoot := createLaunchTestProject(t) var stdout bytes.Buffer var stderr bytes.Buffer - code := runLaunch( + code := runLaunchWithDeps( context.Background(), launchOptions{projectPath: projectRoot}, projectRoot, &stdout, &stderr, + deps, ) if code != 0 { @@ -748,34 +683,28 @@ func TestRunLaunchWritesExistingFocusSuccessVibeLog(t *testing.T) { func TestRunLaunchWritesExistingFocusFailureVibeLog(t *testing.T) { enableCliVibeLog(t) - originalFinder := findRunningUnityProcessForLaunch - originalFocus := focusUnityProcessForLaunch - originalReadinessWait := waitForToolReadinessForLaunch - findRunningUnityProcessForLaunch = func(context.Context, string) (*clicore.UnityProcess, error) { + deps := defaultLaunchDeps() + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return &clicore.UnityProcess{Pid: 222}, nil } - focusUnityProcessForLaunch = func(context.Context, int) error { + deps.focusUnityProcess = func(context.Context, int) error { return fmt.Errorf("activation denied") } - waitForToolReadinessForLaunch = func(context.Context, string, time.Duration) error { + deps.waitForToolReadiness = func(context.Context, string, time.Duration) error { return nil } - t.Cleanup(func() { - findRunningUnityProcessForLaunch = originalFinder - focusUnityProcessForLaunch = originalFocus - waitForToolReadinessForLaunch = originalReadinessWait - }) projectRoot := createLaunchTestProject(t) var stdout bytes.Buffer var stderr bytes.Buffer - code := runLaunch( + code := runLaunchWithDeps( context.Background(), launchOptions{projectPath: projectRoot}, projectRoot, &stdout, &stderr, + deps, ) if code != 0 { @@ -864,19 +793,16 @@ func TestWaitForUnityStartupMarkerReturnsNilWhenLockfileDoesNotAppear(t *testing func TestWaitForUnityProcessExitBoundsProcessScan(t *testing.T) { // Verifies exit waiting applies the exit timeout to each running-process scan. - originalFinder := findRunningUnityProcessForLaunch - findRunningUnityProcessForLaunch = func(ctx context.Context, projectRoot string) (*clicore.UnityProcess, error) { + deps := defaultLaunchDeps() + deps.findRunningUnityProcess = func(ctx context.Context, projectRoot string) (*clicore.UnityProcess, error) { if _, ok := ctx.Deadline(); !ok { return nil, errors.New("missing process scan deadline") } <-ctx.Done() return nil, ctx.Err() } - t.Cleanup(func() { - findRunningUnityProcessForLaunch = originalFinder - }) - err := waitForUnityProcessExit(context.Background(), t.TempDir(), 123, time.Hour, 10*time.Millisecond) + err := waitForUnityProcessExitWithDeps(context.Background(), t.TempDir(), 123, time.Hour, 10*time.Millisecond, deps) var timeoutErr launchProcessExitTimeoutError if !errors.As(err, &timeoutErr) { @@ -955,24 +881,19 @@ func decodeLaunchResponseFromOutput(t *testing.T, output string) launchReadyResp // Verifies launch survives a blocked process scan (e.g. sandboxed /bin/ps) by // probing the project IPC and reporting the running Editor instead of failing. func TestRunLaunchFallsBackToIpcProbeWhenProcessScanFails(t *testing.T) { - originalFinder := findRunningUnityProcessForLaunch - originalProbe := probeProjectIpcForLaunchFallback - findRunningUnityProcessForLaunch = func(context.Context, string) (*clicore.UnityProcess, error) { + deps := defaultLaunchDeps() + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return nil, errors.New("failed to retrieve Unity process list: /bin/ps: operation not permitted") } - probeProjectIpcForLaunchFallback = func(context.Context, string) error { + deps.probeProjectIpcFallback = func(context.Context, string) error { return nil } - t.Cleanup(func() { - findRunningUnityProcessForLaunch = originalFinder - probeProjectIpcForLaunchFallback = originalProbe - }) projectRoot := createLaunchTestProject(t) var stdout bytes.Buffer var stderr bytes.Buffer - code := runLaunch(context.Background(), launchOptions{projectPath: projectRoot}, projectRoot, &stdout, &stderr) + code := runLaunchWithDeps(context.Background(), launchOptions{projectPath: projectRoot}, projectRoot, &stdout, &stderr, deps) if code != 0 { t.Fatalf("exit code mismatch: %d stderr=%s", code, stderr.String()) @@ -991,24 +912,19 @@ func TestRunLaunchFallsBackToIpcProbeWhenProcessScanFails(t *testing.T) { // Verifies launch still fails when the process scan is blocked and the project IPC is silent. func TestRunLaunchReportsScanErrorWhenIpcProbeAlsoFails(t *testing.T) { - originalFinder := findRunningUnityProcessForLaunch - originalProbe := probeProjectIpcForLaunchFallback - findRunningUnityProcessForLaunch = func(context.Context, string) (*clicore.UnityProcess, error) { + deps := defaultLaunchDeps() + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return nil, errors.New("failed to retrieve Unity process list: /bin/ps: operation not permitted") } - probeProjectIpcForLaunchFallback = func(context.Context, string) error { + deps.probeProjectIpcFallback = func(context.Context, string) error { return errors.New("connection refused") } - t.Cleanup(func() { - findRunningUnityProcessForLaunch = originalFinder - probeProjectIpcForLaunchFallback = originalProbe - }) projectRoot := createLaunchTestProject(t) var stdout bytes.Buffer var stderr bytes.Buffer - code := runLaunch(context.Background(), launchOptions{projectPath: projectRoot}, projectRoot, &stdout, &stderr) + code := runLaunchWithDeps(context.Background(), launchOptions{projectPath: projectRoot}, projectRoot, &stdout, &stderr, deps) if code != 1 { t.Fatalf("expected failure, got %d stdout=%s", code, stdout.String()) @@ -1020,25 +936,20 @@ func TestRunLaunchReportsScanErrorWhenIpcProbeAlsoFails(t *testing.T) { // Verifies restart and quit refuse the fallback because they must kill a known process id. func TestRunLaunchRestartDoesNotUseIpcProbeFallback(t *testing.T) { - originalFinder := findRunningUnityProcessForLaunch - originalProbe := probeProjectIpcForLaunchFallback - findRunningUnityProcessForLaunch = func(context.Context, string) (*clicore.UnityProcess, error) { + deps := defaultLaunchDeps() + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return nil, errors.New("failed to retrieve Unity process list: /bin/ps: operation not permitted") } - probeProjectIpcForLaunchFallback = func(context.Context, string) error { + deps.probeProjectIpcFallback = func(context.Context, string) error { t.Fatal("restart must not consult the IPC probe fallback") return nil } - t.Cleanup(func() { - findRunningUnityProcessForLaunch = originalFinder - probeProjectIpcForLaunchFallback = originalProbe - }) projectRoot := createLaunchTestProject(t) var stdout bytes.Buffer var stderr bytes.Buffer - code := runLaunch(context.Background(), launchOptions{restart: true, projectPath: projectRoot}, projectRoot, &stdout, &stderr) + code := runLaunchWithDeps(context.Background(), launchOptions{restart: true, projectPath: projectRoot}, projectRoot, &stdout, &stderr, deps) if code != 1 { t.Fatalf("expected failure, got %d stdout=%s", code, stdout.String()) diff --git a/cli/dispatcher/internal/dispatcher/run_dispatcher.go b/cli/dispatcher/internal/dispatcher/run_dispatcher.go index 47b238cbc8..08f979d8b9 100644 --- a/cli/dispatcher/internal/dispatcher/run_dispatcher.go +++ b/cli/dispatcher/internal/dispatcher/run_dispatcher.go @@ -32,17 +32,31 @@ const ( dispatcherSelfUpdateInterval = 24 * time.Hour ) -var ( - dispatcherNow = time.Now - dispatcherRunRealCLI = runRealCLICommand - dispatcherRunUpdate = runDispatcherUpdateCommand -) - type dispatcherUpdateState struct { LastChecked time.Time `json:"lastChecked"` } +type dispatcherRunDeps struct { + now func() time.Time + runRealCLI func(context.Context, string, []string, io.Writer, io.Writer) int + runUpdate func(context.Context) error + launch launchDeps +} + +func defaultDispatcherRunDeps() dispatcherRunDeps { + return dispatcherRunDeps{ + now: time.Now, + runRealCLI: runRealCLICommand, + runUpdate: runDispatcherUpdateCommand, + launch: defaultLaunchDeps(), + } +} + func RunDispatcher(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer) int { + return runDispatcherWithDeps(ctx, args, stdout, stderr, defaultDispatcherRunDeps()) +} + +func runDispatcherWithDeps(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, deps dispatcherRunDeps) int { if handled, code := tryHandleDispatcherInfoRequest(args, stdout); handled { return code } @@ -54,7 +68,7 @@ func RunDispatcher(ctx context.Context, args []string, stdout io.Writer, stderr } if shouldRunInDispatcherProcess(remainingArgs) { - return runDispatcherProcessCommand(ctx, remainingArgs, projectPath, stdout, stderr) + return runDispatcherProcessCommandWithDeps(ctx, remainingArgs, projectPath, stdout, stderr, deps) } startPath, err := os.Getwd() @@ -75,7 +89,7 @@ func RunDispatcher(ctx context.Context, args []string, stdout io.Writer, stderr return 1 } - if handled, code := enforceDispatcherFreshness(ctx, pin, stderr); handled { + if handled, code := enforceDispatcherFreshnessWithDeps(ctx, pin, stderr, deps); handled { return code } @@ -85,19 +99,20 @@ func RunDispatcher(ctx context.Context, args []string, stdout io.Writer, stderr return 1 } - return dispatcherRunRealCLI(ctx, realCLIPath, args, stdout, stderr) + return deps.runRealCLI(ctx, realCLIPath, args, stdout, stderr) } // runDispatcherProcessCommand executes dispatcher-owned commands without // delegating to the shared runner entrypoint. The dispatcher binary is being // slimmed down to the forwarding machinery plus bootstrap commands, so its // execution path must not run through RunProjectLocal. -func runDispatcherProcessCommand( +func runDispatcherProcessCommandWithDeps( ctx context.Context, remainingArgs []string, projectPath string, stdout io.Writer, stderr io.Writer, + deps dispatcherRunDeps, ) int { if handled, code := tryHandleProjectScopeHelpRequest(remainingArgs, projectPath, stdout); handled { return code @@ -112,7 +127,7 @@ func runDispatcherProcessCommand( return 1 } - if handled, code := tryHandlePreConnectionRequest( + if handled, code := tryHandlePreConnectionRequestWithDeps( ctx, remainingArgs, command, @@ -121,6 +136,7 @@ func runDispatcherProcessCommand( projectPath, stdout, stderr, + deps, ); handled { return code } @@ -172,6 +188,10 @@ func resolveDispatcherProjectRoot(startPath string, explicitProjectPath string, } func enforceDispatcherFreshness(ctx context.Context, pin dispatcherPin, stderr io.Writer) (bool, int) { + return enforceDispatcherFreshnessWithDeps(ctx, pin, stderr, defaultDispatcherRunDeps()) +} + +func enforceDispatcherFreshnessWithDeps(ctx context.Context, pin dispatcherPin, stderr io.Writer, deps dispatcherRunDeps) (bool, int) { minimumVersion := strings.TrimSpace(pin.MinimumDispatcherVersion) if minimumVersion == "" { return false, 0 @@ -187,14 +207,14 @@ func enforceDispatcherFreshness(ctx context.Context, pin dispatcherPin, stderr i } } - updateDue := !dispatcherSelfUpdateDisabled() && dispatcherSelfUpdateDue() + updateDue := !dispatcherSelfUpdateDisabled() && dispatcherSelfUpdateDueWithDeps(deps) if !updateRequired && !updateDue { return false, 0 } - err := dispatcherRunUpdate(ctx) + err := deps.runUpdate(ctx) if err == nil { - markDispatcherSelfUpdateChecked() + markDispatcherSelfUpdateCheckedWithDeps(deps) updatedVersion := dispatcherInstalledVersionOrEmpty(ctx) if updateRequired { writeDispatcherSelfUpdateRequiredError(stderr, updatedVersion) @@ -210,7 +230,7 @@ func enforceDispatcherFreshness(ctx context.Context, pin dispatcherPin, stderr i } // Why: optional update failures should not retry and redraw installer progress on every command. - markDispatcherSelfUpdateChecked() + markDispatcherSelfUpdateCheckedWithDeps(deps) clicore.WriteFormat(stderr, "warning: dispatcher self-update skipped: %v\n", err) return false, 0 } @@ -258,7 +278,7 @@ func dispatcherSelfUpdateDisabled() bool { return value == "1" || strings.EqualFold(value, "true") } -func dispatcherSelfUpdateDue() bool { +func dispatcherSelfUpdateDueWithDeps(deps dispatcherRunDeps) bool { cacheRoot, err := dispatcherCacheRoot(runtime.GOOS) if err != nil { return false @@ -272,10 +292,10 @@ func dispatcherSelfUpdateDue() bool { if err := json.Unmarshal(content, &state); err != nil { return true } - return dispatcherNow().Sub(state.LastChecked) >= dispatcherSelfUpdateInterval + return deps.now().Sub(state.LastChecked) >= dispatcherSelfUpdateInterval } -func markDispatcherSelfUpdateChecked() { +func markDispatcherSelfUpdateCheckedWithDeps(deps dispatcherRunDeps) { cacheRoot, err := dispatcherCacheRoot(runtime.GOOS) if err != nil { return @@ -283,7 +303,7 @@ func markDispatcherSelfUpdateChecked() { if err := os.MkdirAll(cacheRoot, 0o755); err != nil { return } - content, err := json.Marshal(dispatcherUpdateState{LastChecked: dispatcherNow().UTC()}) + content, err := json.Marshal(dispatcherUpdateState{LastChecked: deps.now().UTC()}) if err != nil { return }