diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go index 30dc0e8f5..2d218067f 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go @@ -7,8 +7,10 @@ import ( "io" "os" "path/filepath" + "strings" "testing" + "github.com/hatayama/unity-cli-loop/common/clicore" clierrors "github.com/hatayama/unity-cli-loop/common/errors" "github.com/hatayama/unity-cli-loop/dispatcher/internal/nativepath" ) @@ -343,6 +345,32 @@ func TestRunDispatcherDelegatesResolvedV2PackageDespiteStalePin(t *testing.T) { } } +// Verifies launch stays in the native dispatcher for a V2 project while live commands still use the V2 CLI. +func TestRunDispatcherKeepsV2LaunchInNativeDispatcher(t *testing.T) { + projectRoot := createDispatcherUnityProject(t) + writeV2PackageManifest(t, projectRoot) + writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0") + t.Chdir(projectRoot) + + deps := defaultDispatcherRunDeps() + deps.runV2CLI = func(context.Context, string, []string, io.Writer, io.Writer) (int, error) { + t.Fatal("V2 launch must not be delegated to the V2 CLI") + return 0, nil + } + deps.launch.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { + return nil, nil + } + + var stdout bytes.Buffer + code := runDispatcherWithDeps(context.Background(), []string{"launch", "--quit", projectRoot}, &stdout, io.Discard, deps) + if code != 0 { + t.Fatalf("V2 launch quit exit code = %d", code) + } + if !strings.Contains(stdout.String(), `"Quit": true`) { + t.Fatalf("native V2 launch response missing quit result: %s", stdout.String()) + } +} + func TestRunDispatcherForwardsResolvedV3PackageToPinnedRunner(t *testing.T) { // Verifies a resolved V3 lock version keeps using the pinned project runner. projectRoot := createDispatcherUnityProject(t) diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go b/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go index ebabbefe9..557927ff9 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go @@ -8,6 +8,7 @@ import ( "os/exec" "runtime" + "github.com/hatayama/unity-cli-loop/common/clicore" clierrors "github.com/hatayama/unity-cli-loop/common/errors" ) @@ -19,6 +20,9 @@ func tryRunDetectedDispatcherV2Project( stderr io.Writer, deps dispatcherRunDeps, ) (bool, int) { + if len(args) > 0 && args[0] == clicore.LaunchCommandName { + return false, 0 + } v2Project, err := detectV2DispatcherProject(projectRoot) if err != nil || !v2Project.IsV2 { return false, 0 diff --git a/cli/dispatcher/internal/dispatcher/launch.go b/cli/dispatcher/internal/dispatcher/launch.go index 8e5d8a547..9bc0c0511 100644 --- a/cli/dispatcher/internal/dispatcher/launch.go +++ b/cli/dispatcher/internal/dispatcher/launch.go @@ -105,6 +105,7 @@ func runLaunchWithDeps(ctx context.Context, options launchOptions, startPath str if !deleteLaunchRecoveryIfRequested(options, projectRoot, stderr) { return 1 } + v2Project, _ := detectV2DispatcherProject(projectRoot) runningProcess, handled, code := findLaunchRunningProcess(ctx, options, projectRoot, stdout, stderr, deps) if handled { @@ -112,7 +113,7 @@ func runLaunchWithDeps(ctx context.Context, options launchOptions, startPath str } if runningProcess != nil { - if handled, code := handleExistingLaunchProcess(ctx, options, projectRoot, runningProcess, stdout, stderr, deps); handled { + if handled, code := handleExistingLaunchProcess(ctx, options, projectRoot, runningProcess, v2Project.IsV2, stdout, stderr, deps); handled { return code } } @@ -121,7 +122,7 @@ func runLaunchWithDeps(ctx context.Context, options launchOptions, startPath str return writeLaunchQuitResponse(stdout, stderr, projectRoot, nil, launchNoProcessMessage) } - return startUnityAndWaitForReadiness(ctx, options, projectRoot, runningProcess, stdout, stderr, deps) + return startUnityAndWaitForReadiness(ctx, options, projectRoot, runningProcess, v2Project.IsV2, stdout, stderr, deps) } func writeLaunchProjectSearch(stdout io.Writer, options launchOptions, startPath string) { @@ -173,6 +174,7 @@ func handleExistingLaunchProcess( options launchOptions, projectRoot string, runningProcess *unityprocess.UnityProcess, + isV2 bool, stdout io.Writer, stderr io.Writer, deps launchDeps, @@ -182,6 +184,9 @@ func handleExistingLaunchProcess( clierrors.WriteClassifiedError(stderr, launchEditorVersionRequiresRestartError(options.editorVersion), clierrors.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) return true, 1 } + if isV2 { + return true, writeExistingV2LaunchOpenedResponse(stdout, stderr, projectRoot, runningProcess.Pid) + } return true, waitForExistingLaunchReadiness(ctx, projectRoot, runningProcess.Pid, stdout, stderr, deps) } if err := deps.killUnityProcess(runningProcess.Pid); err != nil { @@ -228,6 +233,7 @@ func startUnityAndWaitForReadiness( options launchOptions, projectRoot string, runningProcess *unityprocess.UnityProcess, + isV2 bool, stdout io.Writer, stderr io.Writer, deps launchDeps, @@ -265,6 +271,7 @@ func startUnityAndWaitForReadiness( clicore.WriteFormat(stdout, "Detected Unity version: %s\n", unityVersion) clicore.WriteLine(stdout, "Unity Hub launch options: none") + launchStartedAt := deps.now() command := newUnityLaunchCommand(unityPath, buildUnityLaunchArgs(projectRoot, options)) if err := command.Start(); err != nil { clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) @@ -275,6 +282,9 @@ func startUnityAndWaitForReadiness( clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) return 1 } + if isV2 { + return waitForV2ProjectOpened(ctx, projectRoot, runningProcess, currentPid, stdout, stderr, launchStartedAt, deps) + } if err := deps.waitForUnityStartupMarker(ctx, unityLockfilePath(projectRoot), launchLockfilePoll, launchLockfileTimeout); err != nil { clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName}) return 1 @@ -432,38 +442,6 @@ func unityExecutableCandidates(version string) []string { } } -func windowsUnityExecutableCandidates(version string) []string { - candidates := []string{} - for _, base := range []string{ - os.Getenv("ProgramFiles"), - os.Getenv("ProgramFiles(x86)"), - os.Getenv("LOCALAPPDATA"), - `C:\Program Files`, - } { - if base == "" { - continue - } - candidates = append(candidates, filepath.Join(base, "Unity", "Hub", "Editor", version, "Editor", "Unity.exe")) - } - return candidates -} - -func readUnityEditorVersion(projectRoot string) (string, error) { - content, err := os.ReadFile(filepath.Join(projectRoot, projectVersionFilePath)) - if err != nil { - return "", err - } - matches := editorVersionPattern.FindStringSubmatch(string(content)) - if len(matches) != 2 { - return "", fmt.Errorf("unity editor version not found in %s", projectVersionFilePath) - } - version := strings.TrimSpace(matches[1]) - if version == "" { - return "", fmt.Errorf("unity editor version is empty in %s", projectVersionFilePath) - } - return version, nil -} - func killUnityProcess(pid int) error { process, err := os.FindProcess(pid) if err != nil { diff --git a/cli/dispatcher/internal/dispatcher/launch_deps.go b/cli/dispatcher/internal/dispatcher/launch_deps.go index 42a0da6ed..75e6e3cb8 100644 --- a/cli/dispatcher/internal/dispatcher/launch_deps.go +++ b/cli/dispatcher/internal/dispatcher/launch_deps.go @@ -9,24 +9,28 @@ import ( ) type launchDeps struct { + now func() time.Time findRunningUnityProcess func(context.Context, string) (*unityprocess.UnityProcess, error) focusUnityProcess func(context.Context, int) error killUnityProcess func(int) error resolveUnityExecutablePath func(string) (string, error) waitForUnityProcessExit func(context.Context, string, int, time.Duration, time.Duration) error waitForUnityStartupMarker func(context.Context, string, time.Duration, time.Duration) error + waitForFreshUnityLockfile func(context.Context, string, time.Time, time.Duration, time.Duration) error waitForToolReadiness func(context.Context, string, time.Duration) error probeProjectIpcFallback func(context.Context, string) error } func defaultLaunchDeps() launchDeps { return launchDeps{ + now: time.Now, findRunningUnityProcess: unityprocess.FindRunningUnityProcess, focusUnityProcess: unityprocess.FocusUnityProcess, killUnityProcess: killUnityProcess, resolveUnityExecutablePath: resolveUnityExecutablePath, waitForUnityProcessExit: waitForUnityProcessExit, waitForUnityStartupMarker: waitForUnityStartupMarkerOrTimeout, + waitForFreshUnityLockfile: waitForFreshUnityLockfile, 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 6b84a8a42..c5fee80ec 100644 --- a/cli/dispatcher/internal/dispatcher/launch_ready.go +++ b/cli/dispatcher/internal/dispatcher/launch_ready.go @@ -70,6 +70,17 @@ func writeExistingLaunchReadyResponse(stdout io.Writer, stderr io.Writer, projec }) } +func writeExistingV2LaunchOpenedResponse(stdout io.Writer, stderr io.Writer, projectRoot string, currentPid int) int { + return writeLaunchResponse(stdout, stderr, launchReadyResponse{ + Success: true, + Ready: true, + AlreadyRunning: true, + CurrentProcessId: ¤tPid, + ProjectRoot: projectRoot, + Message: "Unity is already running for this V2 project. V2 server readiness was not checked.", + }) +} + func writeLaunchedReadyResponse( stdout io.Writer, stderr io.Writer, @@ -91,6 +102,25 @@ func writeLaunchedReadyResponse( }) } +func writeLaunchedV2ProjectOpenedResponse( + stdout io.Writer, + stderr io.Writer, + projectRoot string, + previousPid *int, + currentPid int, +) int { + return writeLaunchResponse(stdout, stderr, launchReadyResponse{ + Success: true, + Ready: true, + 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.", + }) +} + func writeLaunchQuitResponse( stdout io.Writer, stderr io.Writer, diff --git a/cli/dispatcher/internal/dispatcher/launch_test.go b/cli/dispatcher/internal/dispatcher/launch_test.go index 89050dda2..60cdfddbd 100644 --- a/cli/dispatcher/internal/dispatcher/launch_test.go +++ b/cli/dispatcher/internal/dispatcher/launch_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "runtime" @@ -307,6 +308,34 @@ 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) { + 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.waitForFreshUnityLockfile = func(context.Context, string, time.Time, time.Duration, time.Duration) error { 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) + } + response := decodeLaunchResponseFromOutput(t, stdout.String()) + 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") { + t.Fatalf("V2 launch message = %q", response.Message) + } +} + 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_unity.go b/cli/dispatcher/internal/dispatcher/launch_unity.go new file mode 100644 index 000000000..47b07a5c5 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/launch_unity.go @@ -0,0 +1,34 @@ +package dispatcher + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func windowsUnityExecutableCandidates(version string) []string { + candidates := []string{} + for _, base := range []string{os.Getenv("ProgramFiles"), os.Getenv("ProgramFiles(x86)"), os.Getenv("LOCALAPPDATA"), `C:\Program Files`} { + if base != "" { + candidates = append(candidates, filepath.Join(base, "Unity", "Hub", "Editor", version, "Editor", "Unity.exe")) + } + } + return candidates +} + +func readUnityEditorVersion(projectRoot string) (string, error) { + content, err := os.ReadFile(filepath.Join(projectRoot, projectVersionFilePath)) + if err != nil { + return "", err + } + matches := editorVersionPattern.FindStringSubmatch(string(content)) + if len(matches) != 2 { + return "", fmt.Errorf("unity editor version not found in %s", projectVersionFilePath) + } + version := strings.TrimSpace(matches[1]) + if version == "" { + return "", fmt.Errorf("unity editor version is empty in %s", projectVersionFilePath) + } + return version, nil +} diff --git a/cli/dispatcher/internal/dispatcher/launch_v2_lockfile.go b/cli/dispatcher/internal/dispatcher/launch_v2_lockfile.go new file mode 100644 index 000000000..50ebc69ca --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/launch_v2_lockfile.go @@ -0,0 +1,95 @@ +package dispatcher + +import ( + "context" + "fmt" + "io" + "os" + "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/unityprocess" +) + +type v2LaunchLockfileTimeoutError struct { + lockfilePath string +} + +func (err v2LaunchLockfileTimeoutError) Error() string { + return fmt.Sprintf("timed out waiting for Unity to update %s", err.lockfilePath) +} + +func (err v2LaunchLockfileTimeoutError) ToCLIError(context clierrors.ErrorContext) clierrors.CLIError { + return clierrors.CLIError{ + ErrorCode: clierrors.ErrorCodeUnityStartupTimeout, + Phase: clierrors.ErrorPhaseConnection, + Message: "Unity did not open the V2 project before the launch timeout.", + Retryable: true, + SafeToRetry: true, + ProjectRoot: context.ProjectRoot, + Command: context.Command, + NextActions: []string{ + "Check whether the Unity Editor opened this project and is still starting.", + "If Unity appears stuck, focus the Editor and check the Console or Editor log.", + }, + Details: map[string]any{ + "LockfilePath": err.lockfilePath, + "TimeoutSeconds": int(launchReadinessTimeout.Seconds()), + }, + } +} + +// waitForFreshUnityLockfile waits for the Editor process launched by this command to write its own lockfile. +func waitForFreshUnityLockfile( + ctx context.Context, + lockfilePath string, + startedAt time.Time, + pollInterval time.Duration, + timeout time.Duration, +) error { + timeoutContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + for { + fileInfo, err := os.Stat(lockfilePath) + if err == nil && !fileInfo.ModTime().Before(startedAt) { + return nil + } + if err != nil && !os.IsNotExist(err) { + return err + } + + select { + case <-timeoutContext.Done(): + if ctx.Err() != nil { + return ctx.Err() + } + return v2LaunchLockfileTimeoutError{lockfilePath: lockfilePath} + case <-ticker.C: + } + } +} + +func waitForV2ProjectOpened( + ctx context.Context, + projectRoot string, + runningProcess *unityprocess.UnityProcess, + currentPid int, + stdout io.Writer, + stderr io.Writer, + launchStartedAt time.Time, + 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 + } + var previousPid *int + if runningProcess != nil { + previousPid = &runningProcess.Pid + } + return writeLaunchedV2ProjectOpenedResponse(stdout, stderr, projectRoot, previousPid, currentPid) +} diff --git a/cli/dispatcher/internal/dispatcher/launch_v2_lockfile_test.go b/cli/dispatcher/internal/dispatcher/launch_v2_lockfile_test.go new file mode 100644 index 000000000..86438abc0 --- /dev/null +++ b/cli/dispatcher/internal/dispatcher/launch_v2_lockfile_test.go @@ -0,0 +1,45 @@ +package dispatcher + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +// Verifies V2 launch succeeds only after Unity creates or updates its lockfile after the new process starts. +func TestWaitForFreshUnityLockfileWaitsForPostLaunchWrite(t *testing.T) { + lockfilePath := filepath.Join(t.TempDir(), unityLockfileName) + startedAt := time.Now() + go func() { + time.Sleep(20 * time.Millisecond) + if err := os.WriteFile(lockfilePath, []byte("lock"), 0o644); err != nil { + panic(err) + } + }() + + err := waitForFreshUnityLockfile(context.Background(), lockfilePath, startedAt, 5*time.Millisecond, time.Second) + if err != nil { + t.Fatalf("wait for fresh lockfile: %v", err) + } +} + +// Verifies a stale UnityLockfile left by an earlier Editor session cannot satisfy V2 launch readiness. +func TestWaitForFreshUnityLockfileRejectsStaleLockfile(t *testing.T) { + lockfilePath := filepath.Join(t.TempDir(), unityLockfileName) + if err := os.WriteFile(lockfilePath, []byte("stale"), 0o644); err != nil { + t.Fatalf("write stale lockfile: %v", err) + } + staleTime := time.Now().Add(-time.Second) + if err := os.Chtimes(lockfilePath, staleTime, staleTime); err != nil { + t.Fatalf("set stale lockfile time: %v", err) + } + + err := waitForFreshUnityLockfile(context.Background(), lockfilePath, time.Now(), 5*time.Millisecond, 30*time.Millisecond) + var timeoutErr v2LaunchLockfileTimeoutError + if !errors.As(err, &timeoutErr) { + t.Fatalf("expected V2 lockfile timeout, got %v", err) + } +}