diff --git a/cli/dispatcher/internal/dispatcher/launch.go b/cli/dispatcher/internal/dispatcher/launch.go index ea2d938abb..f44415990c 100644 --- a/cli/dispatcher/internal/dispatcher/launch.go +++ b/cli/dispatcher/internal/dispatcher/launch.go @@ -22,15 +22,17 @@ import ( ) const ( - launchLockfilePoll = 100 * time.Millisecond - launchLockfileTimeout = 5 * time.Second - launchProcessExitPoll = 100 * time.Millisecond - launchProcessExitTimeout = 20 * time.Second - launchReadinessTimeout = 10 * time.Minute - projectVersionFilePath = "ProjectSettings/ProjectVersion.txt" - recoveryDirectoryPath = "Assets/_Recovery" - launchTempDirectoryName = "Temp" - unityLockfileName = "UnityLockfile" + launchLockfilePoll = 100 * time.Millisecond + launchLockfileTimeout = 5 * time.Second + launchProcessExitPoll = 100 * time.Millisecond + launchProcessExitTimeout = 20 * time.Second + launchReadinessTimeout = 10 * time.Minute + launchV2ServerReadyPoll = 500 * time.Millisecond + launchV2ServerDialTimeout = 1 * time.Second + projectVersionFilePath = "ProjectSettings/ProjectVersion.txt" + recoveryDirectoryPath = "Assets/_Recovery" + launchTempDirectoryName = "Temp" + unityLockfileName = "UnityLockfile" ) var editorVersionPattern = regexp.MustCompile(`(?m)^m_EditorVersion:\s*(.+)$`) @@ -106,6 +108,11 @@ func runLaunchWithDeps(ctx context.Context, options launchOptions, startPath str return 1 } v2Project, _ := detectV2DispatcherProject(projectRoot) + // Capture before kill (-r) or start so session-id generation compare sees the prior generation. + previousV2ServerSessionID := "" + if v2Project.IsV2 { + previousV2ServerSessionID = readPreviousV2ServerSessionID(projectRoot) + } runningProcess, handled, code := findLaunchRunningProcess(ctx, options, projectRoot, stdout, stderr, deps) if handled { @@ -122,7 +129,7 @@ func runLaunchWithDeps(ctx context.Context, options launchOptions, startPath str return writeLaunchQuitResponse(stdout, stderr, projectRoot, nil, launchNoProcessMessage) } - return startUnityAndWaitForReadiness(ctx, options, projectRoot, runningProcess, v2Project.IsV2, stdout, stderr, deps) + return startUnityAndWaitForReadiness(ctx, options, projectRoot, runningProcess, v2Project.IsV2, previousV2ServerSessionID, stdout, stderr, deps) } func writeLaunchProjectSearch(stdout io.Writer, options launchOptions, startPath string) { @@ -185,7 +192,7 @@ func handleExistingLaunchProcess( return true, 1 } if isV2 { - return true, writeExistingV2LaunchOpenedResponse(stdout, stderr, projectRoot, runningProcess.Pid) + return true, waitForExistingV2LaunchReadiness(ctx, projectRoot, runningProcess.Pid, stdout, stderr, deps) } return true, waitForExistingLaunchReadiness(ctx, projectRoot, runningProcess.Pid, stdout, stderr, deps) } @@ -234,6 +241,7 @@ func startUnityAndWaitForReadiness( projectRoot string, runningProcess *unityprocess.UnityProcess, isV2 bool, + previousV2ServerSessionID string, stdout io.Writer, stderr io.Writer, deps launchDeps, @@ -279,7 +287,7 @@ func startUnityAndWaitForReadiness( return 1 } if isV2 { - return waitForV2ProjectOpened(ctx, projectRoot, runningProcess, currentPid, stdout, stderr, launchStartedAt, deps) + return waitForV2ProjectOpened(ctx, projectRoot, runningProcess, currentPid, stdout, stderr, launchStartedAt, previousV2ServerSessionID, spinner, deps) } if err := deps.waitForUnityStartupMarker(ctx, unityLockfilePath(projectRoot), launchLockfilePoll, launchLockfileTimeout); err != nil { clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) diff --git a/cli/dispatcher/internal/dispatcher/launch_deps.go b/cli/dispatcher/internal/dispatcher/launch_deps.go index 75e6e3cb83..de87da95ec 100644 --- a/cli/dispatcher/internal/dispatcher/launch_deps.go +++ b/cli/dispatcher/internal/dispatcher/launch_deps.go @@ -17,6 +17,7 @@ type launchDeps struct { waitForUnityProcessExit func(context.Context, string, int, time.Duration, time.Duration) error waitForUnityStartupMarker func(context.Context, string, time.Duration, time.Duration) error waitForFreshUnityLockfile func(context.Context, string, time.Time, time.Duration, time.Duration) error + waitForV2ServerReady func(context.Context, string, string, time.Duration, time.Duration) error waitForToolReadiness func(context.Context, string, time.Duration) error probeProjectIpcFallback func(context.Context, string) error } @@ -31,7 +32,10 @@ func defaultLaunchDeps() launchDeps { waitForUnityProcessExit: waitForUnityProcessExit, waitForUnityStartupMarker: waitForUnityStartupMarkerOrTimeout, waitForFreshUnityLockfile: waitForFreshUnityLockfile, - waitForToolReadiness: clicore.WaitForToolReadinessWithTimeout, - probeProjectIpcFallback: clicore.ProbeToolReadinessSequence, + waitForV2ServerReady: func(ctx context.Context, projectRoot string, previousServerSessionID string, poll time.Duration, timeout time.Duration) error { + return waitForV2ServerReady(ctx, projectRoot, previousServerSessionID, defaultV2ServerDial, poll, timeout) + }, + 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 03fae4815b..fc629b7fb0 100644 --- a/cli/dispatcher/internal/dispatcher/launch_focus_log.go +++ b/cli/dispatcher/internal/dispatcher/launch_focus_log.go @@ -57,3 +57,55 @@ func logLaunchExistingFocusFailure(projectRoot string, pid int, focusErr error, CorrelationID: correlationID, }) } + +// logLaunchV2FocusWithDeps focuses a freshly spawned V2 Editor so delayCall can tick. +// Failure is non-fatal: the readiness probe continues either way. +func logLaunchV2FocusWithDeps(ctx context.Context, projectRoot string, pid int, deps launchDeps) { + correlationID := vibelog.NewCLIVibeCorrelationID() + logLaunchV2FocusAttempt(projectRoot, pid, correlationID) + if err := deps.focusUnityProcess(ctx, pid); err != nil { + logLaunchV2FocusFailure(projectRoot, pid, err, correlationID) + return + } + logLaunchV2FocusSuccess(projectRoot, pid, correlationID) +} + +func logLaunchV2FocusAttempt(projectRoot string, pid int, correlationID string) { + _ = vibelog.WriteCLIVibeLog(projectRoot, vibelog.CLIVibeLogEntry{ + Level: "INFO", + Operation: "cli_launch_v2_focus_attempt", + Message: "Attempting to focus the newly launched V2 Unity process so server startup can tick.", + Context: map[string]any{ + "command": "launch", + "pid": pid, + }, + CorrelationID: correlationID, + }) +} + +func logLaunchV2FocusSuccess(projectRoot string, pid int, correlationID string) { + _ = vibelog.WriteCLIVibeLog(projectRoot, vibelog.CLIVibeLogEntry{ + Level: "INFO", + Operation: "cli_launch_v2_focus_success", + Message: "Focused the newly launched V2 Unity process.", + Context: map[string]any{ + "command": "launch", + "pid": pid, + }, + CorrelationID: correlationID, + }) +} + +func logLaunchV2FocusFailure(projectRoot string, pid int, focusErr error, correlationID string) { + _ = vibelog.WriteCLIVibeLog(projectRoot, vibelog.CLIVibeLogEntry{ + Level: "WARNING", + Operation: "cli_launch_v2_focus_failed", + Message: "Failed to focus the newly launched V2 Unity process; continuing readiness probe.", + Context: map[string]any{ + "command": "launch", + "pid": pid, + "focusError": clicore.ErrorMessage(focusErr), + }, + CorrelationID: correlationID, + }) +} diff --git a/cli/dispatcher/internal/dispatcher/launch_ready.go b/cli/dispatcher/internal/dispatcher/launch_ready.go index c5fee80ecc..03d16e1239 100644 --- a/cli/dispatcher/internal/dispatcher/launch_ready.go +++ b/cli/dispatcher/internal/dispatcher/launch_ready.go @@ -70,14 +70,16 @@ func writeExistingLaunchReadyResponse(stdout io.Writer, stderr io.Writer, projec }) } -func writeExistingV2LaunchOpenedResponse(stdout io.Writer, stderr io.Writer, projectRoot string, currentPid int) int { +func writeExistingV2LaunchReadyResponse(stdout io.Writer, stderr io.Writer, projectRoot string, currentPid int) int { return writeLaunchResponse(stdout, stderr, launchReadyResponse{ Success: true, Ready: true, + ServerReady: true, + ProjectIpcReady: false, AlreadyRunning: true, CurrentProcessId: ¤tPid, ProjectRoot: projectRoot, - Message: "Unity is already running for this V2 project. V2 server readiness was not checked.", + Message: "Unity is already running and the V2 uloop server is ready. This project uses uloop V2; commands run through the V2 CLI.", }) } @@ -102,7 +104,7 @@ func writeLaunchedReadyResponse( }) } -func writeLaunchedV2ProjectOpenedResponse( +func writeLaunchedV2ReadyResponse( stdout io.Writer, stderr io.Writer, projectRoot string, @@ -112,12 +114,14 @@ func writeLaunchedV2ProjectOpenedResponse( return writeLaunchResponse(stdout, stderr, launchReadyResponse{ Success: true, Ready: true, + ServerReady: true, + ProjectIpcReady: false, Launched: true, Restarted: previousPid != nil, PreviousProcessId: previousPid, CurrentProcessId: ¤tPid, ProjectRoot: projectRoot, - Message: "Unity started and opened the V2 project. V2 server readiness was not checked.", + Message: "Unity started and the V2 uloop server is ready. This project uses uloop V2; commands run through the V2 CLI.", }) } diff --git a/cli/dispatcher/internal/dispatcher/launch_test.go b/cli/dispatcher/internal/dispatcher/launch_test.go index 6e8075aded..afcff84878 100644 --- a/cli/dispatcher/internal/dispatcher/launch_test.go +++ b/cli/dispatcher/internal/dispatcher/launch_test.go @@ -308,15 +308,25 @@ func TestRunLaunchWritesReadyResponseAfterToolReadiness(t *testing.T) { } } -// Verifies a V2 launch confirms only that Unity opened the project and never waits for the V3 named pipe. -func TestRunLaunchForV2ProjectWaitsForFreshLockfileWithoutServerProbe(t *testing.T) { +// Verifies a V2 fresh launch waits for the lockfile and V2 server readiness, never the V3 named pipe. +func TestRunLaunchForV2ProjectWaitsForLockfileAndServerReady(t *testing.T) { projectRoot := createLaunchTestProject(t) writeV2PackageManifest(t, projectRoot) writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") deps := defaultLaunchDeps() + lockfileWaited := false + v2ServerWaited := false deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return nil, nil } deps.resolveUnityExecutablePath = func(string) (string, error) { return fakeUnityExecutablePath(t), nil } - deps.waitForFreshUnityLockfile = func(context.Context, string, time.Time, time.Duration, time.Duration) error { return nil } + deps.focusUnityProcess = func(context.Context, int) error { return nil } + deps.waitForFreshUnityLockfile = func(context.Context, string, time.Time, time.Duration, time.Duration) error { + lockfileWaited = true + return nil + } + deps.waitForV2ServerReady = func(context.Context, string, string, time.Duration, time.Duration) error { + v2ServerWaited = true + return nil + } deps.waitForToolReadiness = func(context.Context, string, time.Duration) error { t.Fatal("V2 launch must not wait for the V3 server") return nil @@ -327,15 +337,181 @@ func TestRunLaunchForV2ProjectWaitsForFreshLockfileWithoutServerProbe(t *testing if code != 0 { t.Fatalf("V2 launch exit code = %d", code) } + if !lockfileWaited || !v2ServerWaited { + t.Fatalf("V2 launch waits mismatch: lockfile=%v server=%v", lockfileWaited, v2ServerWaited) + } response := decodeLaunchResponseFromOutput(t, stdout.String()) - if !response.Success || !response.Ready || response.ServerReady || response.ProjectIpcReady { + if !response.Success || !response.Ready || !response.ServerReady || response.ProjectIpcReady { t.Fatalf("V2 launch readiness flags = %#v", response) } - if !strings.Contains(response.Message, "V2 server readiness was not checked") { + if !strings.Contains(response.Message, "V2 uloop server is ready") { t.Fatalf("V2 launch message = %q", response.Message) } } +// Verifies V2 fresh launch focuses the Editor before the server probe, and continues when focus fails. +func TestRunLaunchForV2ProjectFocusesEditorBeforeServerProbe(t *testing.T) { + projectRoot := createLaunchTestProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + deps := defaultLaunchDeps() + var callOrder []string + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return nil, nil } + deps.resolveUnityExecutablePath = func(string) (string, error) { return fakeUnityExecutablePath(t), nil } + deps.waitForFreshUnityLockfile = func(context.Context, string, time.Time, time.Duration, time.Duration) error { + callOrder = append(callOrder, "lockfile") + return nil + } + deps.focusUnityProcess = func(context.Context, int) error { + callOrder = append(callOrder, "focus") + return errors.New("focus failed for test") + } + deps.waitForV2ServerReady = func(context.Context, string, string, time.Duration, time.Duration) error { + callOrder = append(callOrder, "probe") + return nil + } + deps.waitForToolReadiness = func(context.Context, string, time.Duration) error { + t.Fatal("V2 launch must not wait for the V3 server") + return nil + } + + var stdout bytes.Buffer + code := runLaunchWithDeps(context.Background(), launchOptions{projectPath: projectRoot, editorVersion: "6000.0.0f1"}, projectRoot, &stdout, io.Discard, deps) + if code != 0 { + t.Fatalf("V2 launch exit code = %d", code) + } + expectedOrder := []string{"lockfile", "focus", "probe"} + if !slices.Equal(callOrder, expectedOrder) { + t.Fatalf("call order = %v, want %v", callOrder, expectedOrder) + } +} + +// Verifies an already-running V2 Editor probes with previousServerSessionID="" and reports AlreadyRunning. +func TestRunLaunchForExistingV2ProjectWaitsForServerReady(t *testing.T) { + projectRoot := createLaunchTestProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + deps := defaultLaunchDeps() + var capturedPreviousSessionID string + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + return &clicore.UnityProcess{Pid: 333}, nil + } + deps.focusUnityProcess = func(context.Context, int) error { return nil } + deps.waitForV2ServerReady = func(_ context.Context, _ string, previousServerSessionID string, _ time.Duration, _ time.Duration) error { + capturedPreviousSessionID = previousServerSessionID + return nil + } + deps.waitForToolReadiness = func(context.Context, string, time.Duration) error { + t.Fatal("V2 existing launch must not wait for the V3 server") + return nil + } + + var stdout bytes.Buffer + code := runLaunchWithDeps(context.Background(), launchOptions{projectPath: projectRoot}, projectRoot, &stdout, io.Discard, deps) + if code != 0 { + t.Fatalf("V2 existing launch exit code = %d", code) + } + if capturedPreviousSessionID != "" { + t.Fatalf("existing V2 path must pass empty previous session id, got %q", capturedPreviousSessionID) + } + response := decodeLaunchResponseFromOutput(t, stdout.String()) + if !response.Success || !response.Ready || !response.ServerReady || response.ProjectIpcReady { + t.Fatalf("V2 existing readiness flags = %#v", response) + } + if !response.AlreadyRunning || response.Launched || response.Restarted { + t.Fatalf("V2 existing process flags = %#v", response) + } +} + +// Verifies -r captures the pre-kill serverSessionId and passes it to the V2 readiness probe. +func TestRunLaunchForV2RestartPassesPreviousServerSessionID(t *testing.T) { + projectRoot := createLaunchTestProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsFileName, v2ServerSettings{ + CustomPort: 8700, + IsServerRunning: true, + ServerSessionID: "pre-restart-session", + }) + deps := defaultLaunchDeps() + var capturedPreviousSessionID string + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + return &clicore.UnityProcess{Pid: 444}, nil + } + deps.killUnityProcess = func(int) error { return nil } + deps.waitForUnityProcessExit = func(context.Context, string, int, time.Duration, time.Duration) error { return nil } + deps.resolveUnityExecutablePath = func(string) (string, error) { return fakeUnityExecutablePath(t), nil } + deps.focusUnityProcess = func(context.Context, int) error { return nil } + deps.waitForFreshUnityLockfile = func(context.Context, string, time.Time, time.Duration, time.Duration) error { + return nil + } + deps.waitForV2ServerReady = func(_ context.Context, _ string, previousServerSessionID string, _ time.Duration, _ time.Duration) error { + capturedPreviousSessionID = previousServerSessionID + return nil + } + deps.waitForToolReadiness = func(context.Context, string, time.Duration) error { + t.Fatal("V2 restart must not wait for the V3 server") + return nil + } + + var stdout bytes.Buffer + code := runLaunchWithDeps( + context.Background(), + launchOptions{projectPath: projectRoot, restart: true, editorVersion: "6000.0.0f1"}, + projectRoot, + &stdout, + io.Discard, + deps, + ) + if code != 0 { + t.Fatalf("V2 restart exit code = %d", code) + } + if capturedPreviousSessionID != "pre-restart-session" { + t.Fatalf("previous session id mismatch: %q", capturedPreviousSessionID) + } + response := decodeLaunchResponseFromOutput(t, stdout.String()) + if !response.Success || !response.Ready || !response.ServerReady || !response.Restarted { + t.Fatalf("V2 restart response = %#v", response) + } +} + +// Verifies a V2 server readiness timeout exits 1 and surfaces the classified timeout error. +func TestRunLaunchForV2ProjectReportsServerReadyTimeout(t *testing.T) { + projectRoot := createLaunchTestProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + deps := defaultLaunchDeps() + deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return nil, nil } + deps.resolveUnityExecutablePath = func(string) (string, error) { return fakeUnityExecutablePath(t), nil } + deps.focusUnityProcess = func(context.Context, int) error { return nil } + deps.waitForFreshUnityLockfile = func(context.Context, string, time.Time, time.Duration, time.Duration) error { + return nil + } + deps.waitForV2ServerReady = func(context.Context, string, string, time.Duration, time.Duration) error { + return v2ServerReadyTimeoutError{projectRoot: projectRoot} + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runLaunchWithDeps( + context.Background(), + launchOptions{projectPath: projectRoot, editorVersion: "6000.0.0f1"}, + projectRoot, + &stdout, + &stderr, + deps, + ) + if code != 1 { + t.Fatalf("V2 timeout exit code = %d stdout=%s", code, stdout.String()) + } + if !strings.Contains(stderr.String(), "V2 uloop server did not become reachable") { + t.Fatalf("stderr missing V2 timeout message:\n%s", stderr.String()) + } + if !strings.Contains(stderr.String(), clierrors.ErrorCodeUnityStartupTimeout) { + t.Fatalf("stderr missing startup timeout code:\n%s", stderr.String()) + } +} + func TestWaitForLaunchReadinessUsesLaunchTimeout(t *testing.T) { // Verifies launch gets a longer startup window without changing shared readiness defaults. deps := defaultLaunchDeps() diff --git a/cli/dispatcher/internal/dispatcher/launch_v2_lockfile.go b/cli/dispatcher/internal/dispatcher/launch_v2_lockfile.go index 50ebc69ca0..8f1a6bb637 100644 --- a/cli/dispatcher/internal/dispatcher/launch_v2_lockfile.go +++ b/cli/dispatcher/internal/dispatcher/launch_v2_lockfile.go @@ -9,6 +9,7 @@ import ( "github.com/hatayama/unity-cli-loop/common/clicore" clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/common/ui" "github.com/hatayama/unity-cli-loop/common/unityprocess" ) @@ -81,15 +82,32 @@ func waitForV2ProjectOpened( stdout io.Writer, stderr io.Writer, launchStartedAt time.Time, + previousServerSessionID string, + spinner *ui.TerminalSpinner, deps launchDeps, ) int { if err := deps.waitForFreshUnityLockfile(ctx, unityLockfilePath(projectRoot), launchStartedAt, launchLockfilePoll, launchReadinessTimeout); err != nil { + spinner.Stop() clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) return 1 } + // Why: V2 server auto-start is scheduled on EditorApplication.delayCall. A CLI-spawned, + // backgrounded idle Editor may never tick on its own, so delayCall never runs + // (V3 documents this and uses EditorApplicationTickBridge.SignalTick — + // Packages/src/Editor/Infrastructure/Server/UnityCliLoopServerController.cs:369-372). + // V2 has no equivalent workaround, so focus once after the lockfile gate. Focus failure + // is non-fatal: log and continue into the readiness probe. + logLaunchV2FocusWithDeps(ctx, projectRoot, currentPid, deps) + writeLaunchReadinessWait(stdout, spinner) + if err := deps.waitForV2ServerReady(ctx, projectRoot, previousServerSessionID, launchV2ServerReadyPoll, launchReadinessTimeout); err != nil { + spinner.Stop() + clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) + return 1 + } + spinner.Stop() var previousPid *int if runningProcess != nil { previousPid = &runningProcess.Pid } - return writeLaunchedV2ProjectOpenedResponse(stdout, stderr, projectRoot, previousPid, currentPid) + return writeLaunchedV2ReadyResponse(stdout, stderr, projectRoot, previousPid, currentPid) } diff --git a/cli/dispatcher/internal/dispatcher/launch_v2_server_ready.go b/cli/dispatcher/internal/dispatcher/launch_v2_server_ready.go new file mode 100644 index 0000000000..ed8f51a2d4 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/launch_v2_server_ready.go @@ -0,0 +1,225 @@ +package dispatcher + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/hatayama/unity-cli-loop/common/clicore" + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/common/ui" + "github.com/hatayama/unity-cli-loop/common/vibelog" +) + +const ( + v2UserSettingsDirectoryName = "UserSettings" + v2ServerSettingsFileName = "UnityMcpSettings.json" + v2ServerSettingsTmpFileName = "UnityMcpSettings.json.tmp" + v2CompilingLockFileName = "compiling.lock" + v2DomainReloadLockFileName = "domainreload.lock" + v2ServerLoopbackHost = "127.0.0.1" +) + +// v2ServerSettings holds the V2 Unity package server state written to UserSettings/UnityMcpSettings.json. +type v2ServerSettings struct { + CustomPort int `json:"customPort"` + IsServerRunning bool `json:"isServerRunning"` + ServerSessionID string `json:"serverSessionId"` +} + +type v2ServerReadyTimeoutError struct { + projectRoot string + timeout time.Duration +} + +func (err v2ServerReadyTimeoutError) Error() string { + return fmt.Sprintf("timed out waiting for V2 uloop server readiness in %s", err.projectRoot) +} + +func (err v2ServerReadyTimeoutError) ToCLIError(context clierrors.ErrorContext) clierrors.CLIError { + return clierrors.CLIError{ + ErrorCode: clierrors.ErrorCodeUnityStartupTimeout, + Phase: clierrors.ErrorPhaseConnection, + Message: "Unity opened the V2 project, but the V2 uloop server did not become reachable before the launch timeout.", + Retryable: true, + SafeToRetry: true, + ProjectRoot: context.ProjectRoot, + Command: context.Command, + NextActions: []string{ + "Check whether the uLoopMCP server is running in the Unity Editor window for this project.", + "Check whether another process is holding the server port and forcing the V2 server into a retry loop.", + "Retry with uloop launch -r to restart Unity and the V2 server.", + }, + Details: map[string]any{ + "ProjectRoot": err.projectRoot, + "TimeoutSeconds": int(err.timeout.Seconds()), + }, + } +} + +// readV2ServerSettings reads V2 server state from UnityMcpSettings.json, falling back to .json.tmp only. +func readV2ServerSettings(projectRoot string) (v2ServerSettings, error) { + candidates := []string{ + filepath.Join(projectRoot, v2UserSettingsDirectoryName, v2ServerSettingsFileName), + filepath.Join(projectRoot, v2UserSettingsDirectoryName, v2ServerSettingsTmpFileName), + } + var lastErr error + for _, candidatePath := range candidates { + settings, err := readV2ServerSettingsFile(candidatePath) + if err == nil { + return settings, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = fmt.Errorf("V2 server settings not found under %s", filepath.Join(projectRoot, v2UserSettingsDirectoryName)) + } + return v2ServerSettings{}, lastErr +} + +func readV2ServerSettingsFile(path string) (v2ServerSettings, error) { + data, err := os.ReadFile(path) + if err != nil { + return v2ServerSettings{}, err + } + data = bytes.TrimPrefix(data, []byte("\xef\xbb\xbf")) + var settings v2ServerSettings + if unmarshalErr := json.Unmarshal(data, &settings); unmarshalErr != nil { + return v2ServerSettings{}, unmarshalErr + } + return settings, nil +} + +// isV2ProjectBusy reports compile or domain-reload locks. serverstarting.lock is intentionally ignored: +// V2 CLI documents that the startup lock can outlive the listener and waiting on it reintroduces false busy; +// sessionId generation already requires SaveRunningServerSession (listen success), so skipping this lock does +// not allow "ready but unreachable" — it only means commands may be cold for a few seconds before prewarm. +func isV2ProjectBusy(projectRoot string) bool { + compilingLockPath := filepath.Join(projectRoot, launchTempDirectoryName, v2CompilingLockFileName) + domainReloadLockPath := filepath.Join(projectRoot, launchTempDirectoryName, v2DomainReloadLockFileName) + if _, err := os.Stat(compilingLockPath); err == nil { + return true + } + if _, err := os.Stat(domainReloadLockPath); err == nil { + return true + } + return false +} + +func defaultV2ServerDial(port int) error { + connection, err := net.DialTimeout( + "tcp", + net.JoinHostPort(v2ServerLoopbackHost, strconv.Itoa(port)), + launchV2ServerDialTimeout, + ) + if err != nil { + return err + } + return connection.Close() +} + +// waitForV2ServerReady polls until the V2 TCP server is reachable for a new serverSessionId generation +// while compile/domain-reload locks are absent. +// +// Why we only TCP-connect (no V2 JSON-RPC ping): the existing-Editor path cannot prove the listener on the +// settings port belongs to this project without implementing V2's framed JSON-RPC ping client in the +// dispatcher — cost that is not justified for launch readiness. Fresh-launch / -r paths already identify +// the session via serverSessionId generation compare. Do not claim "V2 CLI also only connects"; V2 CLI +// does ping-based identification. +func waitForV2ServerReady( + ctx context.Context, + projectRoot string, + previousServerSessionID string, + dial func(port int) error, + poll time.Duration, + timeout time.Duration, +) error { + if dial == nil { + dial = defaultV2ServerDial + } + timeoutContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + ticker := time.NewTicker(poll) + defer ticker.Stop() + + for { + if isV2ServerReadyNow(projectRoot, previousServerSessionID, dial) { + return nil + } + + select { + case <-timeoutContext.Done(): + if ctx.Err() != nil { + return ctx.Err() + } + return v2ServerReadyTimeoutError{projectRoot: projectRoot, timeout: timeout} + case <-ticker.C: + } + } +} + +func isV2ServerReadyNow(projectRoot string, previousServerSessionID string, dial func(port int) error) bool { + settings, err := readV2ServerSettings(projectRoot) + if err != nil { + return false + } + if !settings.IsServerRunning { + return false + } + if settings.ServerSessionID == "" { + return false + } + if settings.ServerSessionID == previousServerSessionID { + return false + } + if dial(settings.CustomPort) != nil { + return false + } + return !isV2ProjectBusy(projectRoot) +} + +func readPreviousV2ServerSessionID(projectRoot string) string { + settings, err := readV2ServerSettings(projectRoot) + if err != nil { + // Missing settings is normal for a never-launched project; keep silent. + // Other read failures still return "" so generation compare degrades safely, + // but leave a WARNING so -r session capture failures are diagnosable. + if !errors.Is(err, fs.ErrNotExist) && !os.IsNotExist(err) { + _ = vibelog.WriteCLIVibeLog(projectRoot, vibelog.CLIVibeLogEntry{ + Level: "WARNING", + Operation: "cli_launch_v2_previous_session_read_failed", + Message: "Failed to read previous V2 serverSessionId before launch; generation compare will treat it as empty.", + Context: map[string]any{ + "command": "launch", + "error": clicore.ErrorMessage(err), + }, + CorrelationID: vibelog.NewCLIVibeCorrelationID(), + }) + } + return "" + } + return settings.ServerSessionID +} + +func waitForExistingV2LaunchReadiness(ctx context.Context, projectRoot string, pid int, stdout io.Writer, stderr io.Writer, deps launchDeps) int { + logLaunchExistingFocusWithDeps(ctx, projectRoot, pid, deps) + spinner := ui.NewLaunchSpinner(stdout, stderr) + defer spinner.Stop() + writeLaunchReadinessWait(stdout, spinner) + if err := deps.waitForV2ServerReady(ctx, projectRoot, "", launchV2ServerReadyPoll, launchReadinessTimeout); err != nil { + spinner.Stop() + clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) + return 1 + } + spinner.Stop() + return writeExistingV2LaunchReadyResponse(stdout, stderr, projectRoot, pid) +} diff --git a/cli/dispatcher/internal/dispatcher/launch_v2_server_ready_test.go b/cli/dispatcher/internal/dispatcher/launch_v2_server_ready_test.go new file mode 100644 index 0000000000..e00d190ace --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/launch_v2_server_ready_test.go @@ -0,0 +1,383 @@ +package dispatcher + +import ( + "context" + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "strconv" + "sync" + "testing" + "time" +) + +const v2ServerSettingsBakFileName = "UnityMcpSettings.json.bak" + +func writeV2ServerSettingsJSON(t *testing.T, projectRoot string, fileName string, settings v2ServerSettings) { + t.Helper() + settingsDirectory := filepath.Join(projectRoot, v2UserSettingsDirectoryName) + if err := os.MkdirAll(settingsDirectory, 0o755); err != nil { + t.Fatalf("mkdir settings: %v", err) + } + payload, err := json.Marshal(settings) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + if err := os.WriteFile(filepath.Join(settingsDirectory, fileName), payload, 0o644); err != nil { + t.Fatalf("write settings %s: %v", fileName, err) + } +} + +func writeV2ServerSettingsRaw(t *testing.T, projectRoot string, fileName string, data []byte) { + t.Helper() + settingsDirectory := filepath.Join(projectRoot, v2UserSettingsDirectoryName) + if err := os.MkdirAll(settingsDirectory, 0o755); err != nil { + t.Fatalf("mkdir settings: %v", err) + } + if err := os.WriteFile(filepath.Join(settingsDirectory, fileName), data, 0o644); err != nil { + t.Fatalf("write settings %s: %v", fileName, err) + } +} + +func startFakeTCPListener(t *testing.T) (int, net.Listener) { + t.Helper() + listener, err := net.Listen("tcp", net.JoinHostPort(v2ServerLoopbackHost, "0")) + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { + _ = listener.Close() + }) + tcpAddress, ok := listener.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("unexpected listener address type %T", listener.Addr()) + } + return tcpAddress.Port, listener +} + +func dialTCPPort(port int) error { + connection, err := net.DialTimeout("tcp", net.JoinHostPort(v2ServerLoopbackHost, strconv.Itoa(port)), 200*time.Millisecond) + if err != nil { + return err + } + return connection.Close() +} + +func marshalV2ServerSettings(t *testing.T, settings v2ServerSettings) []byte { + t.Helper() + payload, err := json.Marshal(settings) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + return payload +} + +func v2ServerSettingsPath(projectRoot string, fileName string) string { + return filepath.Join(projectRoot, v2UserSettingsDirectoryName, fileName) +} + +// Verifies missing V2 settings files produce an error (neither .json nor .tmp present). +func TestReadV2ServerSettingsMissingFile(t *testing.T) { + projectRoot := t.TempDir() + _, err := readV2ServerSettings(projectRoot) + if err == nil { + t.Fatal("expected error when settings are missing") + } +} + +// Verifies a well-formed UnityMcpSettings.json is unmarshaled into v2ServerSettings. +func TestReadV2ServerSettingsReadsValidJSON(t *testing.T) { + projectRoot := t.TempDir() + writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsFileName, v2ServerSettings{ + CustomPort: 8700, + IsServerRunning: true, + ServerSessionID: "session-abc", + }) + + settings, err := readV2ServerSettings(projectRoot) + if err != nil { + t.Fatalf("read settings: %v", err) + } + if settings.CustomPort != 8700 || !settings.IsServerRunning || settings.ServerSessionID != "session-abc" { + t.Fatalf("unexpected settings %#v", settings) + } +} + +// Verifies UTF-8 BOM prefixed settings JSON is still readable. +func TestReadV2ServerSettingsReadsUTF8BOMJSON(t *testing.T) { + projectRoot := t.TempDir() + payload, err := json.Marshal(v2ServerSettings{ + CustomPort: 8701, + IsServerRunning: true, + ServerSessionID: "session-bom", + }) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + writeV2ServerSettingsRaw(t, projectRoot, v2ServerSettingsFileName, append([]byte("\xef\xbb\xbf"), payload...)) + + settings, err := readV2ServerSettings(projectRoot) + if err != nil { + t.Fatalf("read BOM settings: %v", err) + } + if settings.CustomPort != 8701 || settings.ServerSessionID != "session-bom" { + t.Fatalf("unexpected BOM settings %#v", settings) + } +} + +// Verifies a corrupt primary settings file falls back to .json.tmp, and .bak alone is never used. +func TestReadV2ServerSettingsFallsBackToTmpAndIgnoresBak(t *testing.T) { + projectRoot := t.TempDir() + writeV2ServerSettingsRaw(t, projectRoot, v2ServerSettingsFileName, []byte("{not-json")) + writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsTmpFileName, v2ServerSettings{ + CustomPort: 8702, + IsServerRunning: true, + ServerSessionID: "session-tmp", + }) + writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsBakFileName, v2ServerSettings{ + CustomPort: 9999, + IsServerRunning: true, + ServerSessionID: "session-bak", + }) + + settings, err := readV2ServerSettings(projectRoot) + if err != nil { + t.Fatalf("read settings via tmp: %v", err) + } + if settings.CustomPort != 8702 || settings.ServerSessionID != "session-tmp" { + t.Fatalf("expected tmp settings, got %#v", settings) + } + + bakOnlyRoot := t.TempDir() + writeV2ServerSettingsJSON(t, bakOnlyRoot, v2ServerSettingsBakFileName, v2ServerSettings{ + CustomPort: 1, + IsServerRunning: true, + ServerSessionID: "session-bak-only", + }) + _, bakErr := readV2ServerSettings(bakOnlyRoot) + if bakErr == nil { + t.Fatal("expected error when only .bak exists") + } +} + +// Verifies ready conditions: new session id, isServerRunning, dial success, and no compile/reload locks. +func TestWaitForV2ServerReadySucceedsWhenSessionDialAndIdle(t *testing.T) { + projectRoot := t.TempDir() + port, _ := startFakeTCPListener(t) + writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsFileName, v2ServerSettings{ + CustomPort: port, + IsServerRunning: true, + ServerSessionID: "new-session", + }) + + err := waitForV2ServerReady(context.Background(), projectRoot, "old-session", dialTCPPort, 5*time.Millisecond, time.Second) + if err != nil { + t.Fatalf("wait for V2 server ready: %v", err) + } +} + +// Verifies compiling.lock keeps readiness false until the lock is removed. +func TestWaitForV2ServerReadyWaitsWhileCompilingLockPresent(t *testing.T) { + projectRoot := t.TempDir() + port, _ := startFakeTCPListener(t) + tempDirectory := filepath.Join(projectRoot, launchTempDirectoryName) + if err := os.MkdirAll(tempDirectory, 0o755); err != nil { + t.Fatalf("mkdir Temp: %v", err) + } + compilingLockPath := filepath.Join(tempDirectory, v2CompilingLockFileName) + if err := os.WriteFile(compilingLockPath, []byte("busy"), 0o644); err != nil { + t.Fatalf("write compiling.lock: %v", err) + } + writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsFileName, v2ServerSettings{ + CustomPort: port, + IsServerRunning: true, + ServerSessionID: "new-session", + }) + + go func() { + time.Sleep(40 * time.Millisecond) + _ = os.Remove(compilingLockPath) + }() + + err := waitForV2ServerReady(context.Background(), projectRoot, "old-session", dialTCPPort, 5*time.Millisecond, time.Second) + if err != nil { + t.Fatalf("wait while compiling: %v", err) + } +} + +// Verifies domainreload.lock keeps readiness false until the lock is removed. +func TestWaitForV2ServerReadyWaitsWhileDomainReloadLockPresent(t *testing.T) { + projectRoot := t.TempDir() + port, _ := startFakeTCPListener(t) + tempDirectory := filepath.Join(projectRoot, launchTempDirectoryName) + if err := os.MkdirAll(tempDirectory, 0o755); err != nil { + t.Fatalf("mkdir Temp: %v", err) + } + domainReloadLockPath := filepath.Join(tempDirectory, v2DomainReloadLockFileName) + if err := os.WriteFile(domainReloadLockPath, []byte("busy"), 0o644); err != nil { + t.Fatalf("write domainreload.lock: %v", err) + } + writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsFileName, v2ServerSettings{ + CustomPort: port, + IsServerRunning: true, + ServerSessionID: "new-session", + }) + + go func() { + time.Sleep(40 * time.Millisecond) + _ = os.Remove(domainReloadLockPath) + }() + + err := waitForV2ServerReady(context.Background(), projectRoot, "old-session", dialTCPPort, 5*time.Millisecond, time.Second) + if err != nil { + t.Fatalf("wait while domain reload: %v", err) + } +} + +// Verifies readiness waits until serverSessionId differs from the previous generation. +func TestWaitForV2ServerReadyWaitsForSessionIdGenerationChange(t *testing.T) { + projectRoot := t.TempDir() + port, _ := startFakeTCPListener(t) + writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsFileName, v2ServerSettings{ + CustomPort: port, + IsServerRunning: true, + ServerSessionID: "same-session", + }) + changedPayload := marshalV2ServerSettings(t, v2ServerSettings{ + CustomPort: port, + IsServerRunning: true, + ServerSessionID: "changed-session", + }) + settingsPath := v2ServerSettingsPath(projectRoot, v2ServerSettingsFileName) + + var writeErr error + writeDone := make(chan struct{}) + go func() { + defer close(writeDone) + time.Sleep(40 * time.Millisecond) + writeErr = os.WriteFile(settingsPath, changedPayload, 0o644) + }() + + err := waitForV2ServerReady(context.Background(), projectRoot, "same-session", dialTCPPort, 5*time.Millisecond, time.Second) + <-writeDone + if writeErr != nil { + t.Fatalf("write changed session settings: %v", writeErr) + } + if err != nil { + t.Fatalf("wait for session generation: %v", err) + } +} + +// Verifies an empty serverSessionId (port-acquire retry) never satisfies readiness. +func TestWaitForV2ServerReadyRejectsEmptyServerSessionID(t *testing.T) { + projectRoot := t.TempDir() + port, _ := startFakeTCPListener(t) + writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsFileName, v2ServerSettings{ + CustomPort: port, + IsServerRunning: true, + ServerSessionID: "", + }) + + err := waitForV2ServerReady(context.Background(), projectRoot, "", dialTCPPort, 5*time.Millisecond, 40*time.Millisecond) + var timeoutErr v2ServerReadyTimeoutError + if !errors.As(err, &timeoutErr) { + t.Fatalf("expected V2 server ready timeout, got %v", err) + } +} + +// Verifies waitForV2ServerReady returns v2ServerReadyTimeoutError when conditions never become ready. +func TestWaitForV2ServerReadyTimesOut(t *testing.T) { + projectRoot := t.TempDir() + writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsFileName, v2ServerSettings{ + CustomPort: 1, + IsServerRunning: false, + ServerSessionID: "session", + }) + + err := waitForV2ServerReady(context.Background(), projectRoot, "other", dialTCPPort, 5*time.Millisecond, 30*time.Millisecond) + var timeoutErr v2ServerReadyTimeoutError + if !errors.As(err, &timeoutErr) { + t.Fatalf("expected V2 server ready timeout, got %v", err) + } +} + +// Verifies previousServerSessionID="" only requires a non-empty current session id (no generation compare). +func TestWaitForV2ServerReadySucceedsWithEmptyPreviousSessionID(t *testing.T) { + projectRoot := t.TempDir() + port, _ := startFakeTCPListener(t) + writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsFileName, v2ServerSettings{ + CustomPort: port, + IsServerRunning: true, + ServerSessionID: "any-non-empty", + }) + + err := waitForV2ServerReady(context.Background(), projectRoot, "", dialTCPPort, 5*time.Millisecond, time.Second) + if err != nil { + t.Fatalf("wait with empty previous session: %v", err) + } +} + +// Verifies each poll re-reads customPort so a fallback port switch is followed. +func TestWaitForV2ServerReadyFollowsCustomPortChange(t *testing.T) { + projectRoot := t.TempDir() + // Bind then close so settings can advertise a real-looking old port that is not reachable. + oldPort, oldListener := startFakeTCPListener(t) + if err := oldListener.Close(); err != nil { + t.Fatalf("close old listener: %v", err) + } + newPort, _ := startFakeTCPListener(t) + writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsFileName, v2ServerSettings{ + CustomPort: oldPort, + IsServerRunning: true, + ServerSessionID: "new-session", + }) + changedPayload := marshalV2ServerSettings(t, v2ServerSettings{ + CustomPort: newPort, + IsServerRunning: true, + ServerSessionID: "new-session", + }) + settingsPath := v2ServerSettingsPath(projectRoot, v2ServerSettingsFileName) + + var dialedPorts []int + var dialMutex sync.Mutex + dial := func(port int) error { + dialMutex.Lock() + dialedPorts = append(dialedPorts, port) + dialMutex.Unlock() + return dialTCPPort(port) + } + + var writeErr error + writeDone := make(chan struct{}) + go func() { + defer close(writeDone) + time.Sleep(30 * time.Millisecond) + writeErr = os.WriteFile(settingsPath, changedPayload, 0o644) + }() + + err := waitForV2ServerReady(context.Background(), projectRoot, "old-session", dial, 5*time.Millisecond, time.Second) + <-writeDone + if writeErr != nil { + t.Fatalf("write port-change settings: %v", writeErr) + } + if err != nil { + t.Fatalf("wait after port change: %v", err) + } + dialMutex.Lock() + defer dialMutex.Unlock() + sawOldPort := false + sawNewPort := false + for _, port := range dialedPorts { + if port == oldPort { + sawOldPort = true + } + if port == newPort { + sawNewPort = true + } + } + if !sawOldPort || !sawNewPort { + t.Fatalf("expected dials of old %d and new %d, dialed %v", oldPort, newPort, dialedPorts) + } +}