From 73dfa400b86747d4d7d2a3268cbb9394dd28d7bf Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 15:56:48 +0900 Subject: [PATCH 1/3] fix: wait for V2 server readiness in uloop launch Replace PR #1826's lockfile-only wait and "readiness was not checked" response with real readiness: UserSettings/UnityMcpSettings.json serverSessionId generation compare, TCP connect, and compile/reload lock absence. After the lockfile gate, focus the Editor once so V2's delayCall-based server start can tick (V2 has no SignalTick workaround). Co-authored-by: Cursor --- cli/dispatcher/internal/dispatcher/launch.go | 32 +- .../internal/dispatcher/launch_deps.go | 8 +- .../internal/dispatcher/launch_ready.go | 12 +- .../internal/dispatcher/launch_test.go | 186 +++++++++- .../internal/dispatcher/launch_v2_lockfile.go | 19 +- .../dispatcher/launch_v2_server_ready.go | 204 +++++++++++ .../dispatcher/launch_v2_server_ready_test.go | 323 ++++++++++++++++++ 7 files changed, 760 insertions(+), 24 deletions(-) create mode 100644 cli/dispatcher/internal/dispatcher/launch_v2_server_ready.go create mode 100644 cli/dispatcher/internal/dispatcher/launch_v2_server_ready_test.go 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_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..8103321581 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,31 @@ 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 { 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. + logLaunchExistingFocusWithDeps(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..3a93c8976a --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/launch_v2_server_ready.go @@ -0,0 +1,204 @@ +package dispatcher + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "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" +) + +const ( + v2UserSettingsDirectoryName = "UserSettings" + v2ServerSettingsFileName = "UnityMcpSettings.json" + v2ServerSettingsTmpFileName = "UnityMcpSettings.json.tmp" + v2CompilingLockFileName = "compiling.lock" + v2DomainReloadLockFileName = "domainreload.lock" +) + +// 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 +} + +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(launchReadinessTimeout.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("127.0.0.1", 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} + 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 { + 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 { + 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..d4ebf247db --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/launch_v2_server_ready_test.go @@ -0,0 +1,323 @@ +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", "127.0.0.1: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("127.0.0.1", strconv.Itoa(port)), 200*time.Millisecond) + if err != nil { + return err + } + return connection.Close() +} + +// 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 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", + }) + + go func() { + time.Sleep(40 * time.Millisecond) + writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsFileName, v2ServerSettings{ + CustomPort: port, + IsServerRunning: true, + ServerSessionID: "changed-session", + }) + }() + + err := waitForV2ServerReady(context.Background(), projectRoot, "same-session", dialTCPPort, 5*time.Millisecond, time.Second) + 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", + }) + + var dialedPorts []int + var dialMutex sync.Mutex + dial := func(port int) error { + dialMutex.Lock() + dialedPorts = append(dialedPorts, port) + dialMutex.Unlock() + return dialTCPPort(port) + } + + go func() { + time.Sleep(30 * time.Millisecond) + writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsFileName, v2ServerSettings{ + CustomPort: newPort, + IsServerRunning: true, + ServerSessionID: "new-session", + }) + }() + + err := waitForV2ServerReady(context.Background(), projectRoot, "old-session", dial, 5*time.Millisecond, time.Second) + 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) + } +} From d386b27d7aaf2714cf90a2009b1fe363f30418be Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 16:08:10 +0900 Subject: [PATCH 2/3] fix: report actual V2 ready timeout and stop spinner on error Store the wait timeout on v2ServerReadyTimeoutError so Details match the call site, and stop the launch spinner before writing the existing-V2 readiness failure so stderr is not mixed with spinner frames. Co-authored-by: Cursor --- .../internal/dispatcher/launch_v2_server_ready.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/dispatcher/internal/dispatcher/launch_v2_server_ready.go b/cli/dispatcher/internal/dispatcher/launch_v2_server_ready.go index 3a93c8976a..ce7dc83c53 100644 --- a/cli/dispatcher/internal/dispatcher/launch_v2_server_ready.go +++ b/cli/dispatcher/internal/dispatcher/launch_v2_server_ready.go @@ -34,6 +34,7 @@ type v2ServerSettings struct { type v2ServerReadyTimeoutError struct { projectRoot string + timeout time.Duration } func (err v2ServerReadyTimeoutError) Error() string { @@ -56,7 +57,7 @@ func (err v2ServerReadyTimeoutError) ToCLIError(context clierrors.ErrorContext) }, Details: map[string]any{ "ProjectRoot": err.projectRoot, - "TimeoutSeconds": int(launchReadinessTimeout.Seconds()), + "TimeoutSeconds": int(err.timeout.Seconds()), }, } } @@ -156,7 +157,7 @@ func waitForV2ServerReady( if ctx.Err() != nil { return ctx.Err() } - return v2ServerReadyTimeoutError{projectRoot: projectRoot} + return v2ServerReadyTimeoutError{projectRoot: projectRoot, timeout: timeout} case <-ticker.C: } } @@ -196,6 +197,7 @@ func waitForExistingV2LaunchReadiness(ctx context.Context, projectRoot string, p 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 } From 81f45e494b112e3e2cd71436fe966d3adc73b418 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 16:26:48 +0900 Subject: [PATCH 3/3] fix: address PR #2024 final-review nits for V2 launch readiness Cover domainreload.lock busy waits, keep FailNow out of test goroutines, stop the spinner on lockfile timeout, constantize the loopback host, use V2-specific focus vibe logs, and warn when previous sessionId read fails for a reason other than missing files. Co-authored-by: Cursor --- .../internal/dispatcher/launch_focus_log.go | 52 ++++++++++++ .../internal/dispatcher/launch_v2_lockfile.go | 3 +- .../dispatcher/launch_v2_server_ready.go | 21 ++++- .../dispatcher/launch_v2_server_ready_test.go | 84 ++++++++++++++++--- 4 files changed, 146 insertions(+), 14 deletions(-) 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_v2_lockfile.go b/cli/dispatcher/internal/dispatcher/launch_v2_lockfile.go index 8103321581..8f1a6bb637 100644 --- a/cli/dispatcher/internal/dispatcher/launch_v2_lockfile.go +++ b/cli/dispatcher/internal/dispatcher/launch_v2_lockfile.go @@ -87,6 +87,7 @@ func waitForV2ProjectOpened( 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 } @@ -96,7 +97,7 @@ func waitForV2ProjectOpened( // 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. - logLaunchExistingFocusWithDeps(ctx, projectRoot, currentPid, deps) + logLaunchV2FocusWithDeps(ctx, projectRoot, currentPid, deps) writeLaunchReadinessWait(stdout, spinner) if err := deps.waitForV2ServerReady(ctx, projectRoot, previousServerSessionID, launchV2ServerReadyPoll, launchReadinessTimeout); err != nil { spinner.Stop() diff --git a/cli/dispatcher/internal/dispatcher/launch_v2_server_ready.go b/cli/dispatcher/internal/dispatcher/launch_v2_server_ready.go index ce7dc83c53..ed8f51a2d4 100644 --- a/cli/dispatcher/internal/dispatcher/launch_v2_server_ready.go +++ b/cli/dispatcher/internal/dispatcher/launch_v2_server_ready.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" + "io/fs" "net" "os" "path/filepath" @@ -15,6 +17,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/vibelog" ) const ( @@ -23,6 +26,7 @@ const ( 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. @@ -114,7 +118,7 @@ func isV2ProjectBusy(projectRoot string) bool { func defaultV2ServerDial(port int) error { connection, err := net.DialTimeout( "tcp", - net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), + net.JoinHostPort(v2ServerLoopbackHost, strconv.Itoa(port)), launchV2ServerDialTimeout, ) if err != nil { @@ -186,6 +190,21 @@ func isV2ServerReadyNow(projectRoot string, previousServerSessionID string, dial 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 diff --git a/cli/dispatcher/internal/dispatcher/launch_v2_server_ready_test.go b/cli/dispatcher/internal/dispatcher/launch_v2_server_ready_test.go index d4ebf247db..e00d190ace 100644 --- a/cli/dispatcher/internal/dispatcher/launch_v2_server_ready_test.go +++ b/cli/dispatcher/internal/dispatcher/launch_v2_server_ready_test.go @@ -43,7 +43,7 @@ func writeV2ServerSettingsRaw(t *testing.T, projectRoot string, fileName string, func startFakeTCPListener(t *testing.T) (int, net.Listener) { t.Helper() - listener, err := net.Listen("tcp", "127.0.0.1:0") + listener, err := net.Listen("tcp", net.JoinHostPort(v2ServerLoopbackHost, "0")) if err != nil { t.Fatalf("listen: %v", err) } @@ -58,13 +58,26 @@ func startFakeTCPListener(t *testing.T) (int, net.Listener) { } func dialTCPPort(port int) error { - connection, err := net.DialTimeout("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(port)), 200*time.Millisecond) + 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() @@ -194,6 +207,35 @@ func TestWaitForV2ServerReadyWaitsWhileCompilingLockPresent(t *testing.T) { } } +// 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() @@ -203,17 +245,26 @@ func TestWaitForV2ServerReadyWaitsForSessionIdGenerationChange(t *testing.T) { 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) - writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsFileName, v2ServerSettings{ - CustomPort: port, - IsServerRunning: true, - ServerSessionID: "changed-session", - }) + 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) } @@ -282,6 +333,12 @@ func TestWaitForV2ServerReadyFollowsCustomPortChange(t *testing.T) { 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 @@ -292,16 +349,19 @@ func TestWaitForV2ServerReadyFollowsCustomPortChange(t *testing.T) { return dialTCPPort(port) } + var writeErr error + writeDone := make(chan struct{}) go func() { + defer close(writeDone) time.Sleep(30 * time.Millisecond) - writeV2ServerSettingsJSON(t, projectRoot, v2ServerSettingsFileName, v2ServerSettings{ - CustomPort: newPort, - IsServerRunning: true, - ServerSessionID: "new-session", - }) + 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) }