Skip to content

Commit 4ace5b4

Browse files
authored
fix: Launch V2 Unity projects without hanging (#1826)
1 parent 1cc123b commit 4ace5b4

9 files changed

Lines changed: 281 additions & 34 deletions

File tree

cli/dispatcher/internal/dispatcher/dispatcher_v2_detect_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import (
77
"io"
88
"os"
99
"path/filepath"
10+
"strings"
1011
"testing"
1112

13+
"github.com/hatayama/unity-cli-loop/common/clicore"
1214
clierrors "github.com/hatayama/unity-cli-loop/common/errors"
1315
"github.com/hatayama/unity-cli-loop/dispatcher/internal/nativepath"
1416
)
@@ -343,6 +345,32 @@ func TestRunDispatcherDelegatesResolvedV2PackageDespiteStalePin(t *testing.T) {
343345
}
344346
}
345347

348+
// Verifies launch stays in the native dispatcher for a V2 project while live commands still use the V2 CLI.
349+
func TestRunDispatcherKeepsV2LaunchInNativeDispatcher(t *testing.T) {
350+
projectRoot := createDispatcherUnityProject(t)
351+
writeV2PackageManifest(t, projectRoot)
352+
writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0")
353+
t.Chdir(projectRoot)
354+
355+
deps := defaultDispatcherRunDeps()
356+
deps.runV2CLI = func(context.Context, string, []string, io.Writer, io.Writer) (int, error) {
357+
t.Fatal("V2 launch must not be delegated to the V2 CLI")
358+
return 0, nil
359+
}
360+
deps.launch.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) {
361+
return nil, nil
362+
}
363+
364+
var stdout bytes.Buffer
365+
code := runDispatcherWithDeps(context.Background(), []string{"launch", "--quit", projectRoot}, &stdout, io.Discard, deps)
366+
if code != 0 {
367+
t.Fatalf("V2 launch quit exit code = %d", code)
368+
}
369+
if !strings.Contains(stdout.String(), `"Quit": true`) {
370+
t.Fatalf("native V2 launch response missing quit result: %s", stdout.String())
371+
}
372+
}
373+
346374
func TestRunDispatcherForwardsResolvedV3PackageToPinnedRunner(t *testing.T) {
347375
// Verifies a resolved V3 lock version keeps using the pinned project runner.
348376
projectRoot := createDispatcherUnityProject(t)

cli/dispatcher/internal/dispatcher/dispatcher_v2_run.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os/exec"
99
"runtime"
1010

11+
"github.com/hatayama/unity-cli-loop/common/clicore"
1112
clierrors "github.com/hatayama/unity-cli-loop/common/errors"
1213
)
1314

@@ -19,6 +20,9 @@ func tryRunDetectedDispatcherV2Project(
1920
stderr io.Writer,
2021
deps dispatcherRunDeps,
2122
) (bool, int) {
23+
if len(args) > 0 && args[0] == clicore.LaunchCommandName {
24+
return false, 0
25+
}
2226
v2Project, err := detectV2DispatcherProject(projectRoot)
2327
if err != nil || !v2Project.IsV2 {
2428
return false, 0

cli/dispatcher/internal/dispatcher/launch.go

Lines changed: 12 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,15 @@ func runLaunchWithDeps(ctx context.Context, options launchOptions, startPath str
105105
if !deleteLaunchRecoveryIfRequested(options, projectRoot, stderr) {
106106
return 1
107107
}
108+
v2Project, _ := detectV2DispatcherProject(projectRoot)
108109

109110
runningProcess, handled, code := findLaunchRunningProcess(ctx, options, projectRoot, stdout, stderr, deps)
110111
if handled {
111112
return code
112113
}
113114

114115
if runningProcess != nil {
115-
if handled, code := handleExistingLaunchProcess(ctx, options, projectRoot, runningProcess, stdout, stderr, deps); handled {
116+
if handled, code := handleExistingLaunchProcess(ctx, options, projectRoot, runningProcess, v2Project.IsV2, stdout, stderr, deps); handled {
116117
return code
117118
}
118119
}
@@ -121,7 +122,7 @@ func runLaunchWithDeps(ctx context.Context, options launchOptions, startPath str
121122
return writeLaunchQuitResponse(stdout, stderr, projectRoot, nil, launchNoProcessMessage)
122123
}
123124

124-
return startUnityAndWaitForReadiness(ctx, options, projectRoot, runningProcess, stdout, stderr, deps)
125+
return startUnityAndWaitForReadiness(ctx, options, projectRoot, runningProcess, v2Project.IsV2, stdout, stderr, deps)
125126
}
126127

127128
func writeLaunchProjectSearch(stdout io.Writer, options launchOptions, startPath string) {
@@ -173,6 +174,7 @@ func handleExistingLaunchProcess(
173174
options launchOptions,
174175
projectRoot string,
175176
runningProcess *unityprocess.UnityProcess,
177+
isV2 bool,
176178
stdout io.Writer,
177179
stderr io.Writer,
178180
deps launchDeps,
@@ -182,6 +184,9 @@ func handleExistingLaunchProcess(
182184
clierrors.WriteClassifiedError(stderr, launchEditorVersionRequiresRestartError(options.editorVersion), clierrors.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName})
183185
return true, 1
184186
}
187+
if isV2 {
188+
return true, writeExistingV2LaunchOpenedResponse(stdout, stderr, projectRoot, runningProcess.Pid)
189+
}
185190
return true, waitForExistingLaunchReadiness(ctx, projectRoot, runningProcess.Pid, stdout, stderr, deps)
186191
}
187192
if err := deps.killUnityProcess(runningProcess.Pid); err != nil {
@@ -228,6 +233,7 @@ func startUnityAndWaitForReadiness(
228233
options launchOptions,
229234
projectRoot string,
230235
runningProcess *unityprocess.UnityProcess,
236+
isV2 bool,
231237
stdout io.Writer,
232238
stderr io.Writer,
233239
deps launchDeps,
@@ -265,6 +271,7 @@ func startUnityAndWaitForReadiness(
265271
clicore.WriteFormat(stdout, "Detected Unity version: %s\n", unityVersion)
266272
clicore.WriteLine(stdout, "Unity Hub launch options: none")
267273

274+
launchStartedAt := deps.now()
268275
command := newUnityLaunchCommand(unityPath, buildUnityLaunchArgs(projectRoot, options))
269276
if err := command.Start(); err != nil {
270277
clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName})
@@ -275,6 +282,9 @@ func startUnityAndWaitForReadiness(
275282
clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName})
276283
return 1
277284
}
285+
if isV2 {
286+
return waitForV2ProjectOpened(ctx, projectRoot, runningProcess, currentPid, stdout, stderr, launchStartedAt, deps)
287+
}
278288
if err := deps.waitForUnityStartupMarker(ctx, unityLockfilePath(projectRoot), launchLockfilePoll, launchLockfileTimeout); err != nil {
279289
clierrors.WriteClassifiedError(stderr, err, clierrors.ErrorContext{ProjectRoot: projectRoot, Command: clicore.LaunchCommandName})
280290
return 1
@@ -432,38 +442,6 @@ func unityExecutableCandidates(version string) []string {
432442
}
433443
}
434444

435-
func windowsUnityExecutableCandidates(version string) []string {
436-
candidates := []string{}
437-
for _, base := range []string{
438-
os.Getenv("ProgramFiles"),
439-
os.Getenv("ProgramFiles(x86)"),
440-
os.Getenv("LOCALAPPDATA"),
441-
`C:\Program Files`,
442-
} {
443-
if base == "" {
444-
continue
445-
}
446-
candidates = append(candidates, filepath.Join(base, "Unity", "Hub", "Editor", version, "Editor", "Unity.exe"))
447-
}
448-
return candidates
449-
}
450-
451-
func readUnityEditorVersion(projectRoot string) (string, error) {
452-
content, err := os.ReadFile(filepath.Join(projectRoot, projectVersionFilePath))
453-
if err != nil {
454-
return "", err
455-
}
456-
matches := editorVersionPattern.FindStringSubmatch(string(content))
457-
if len(matches) != 2 {
458-
return "", fmt.Errorf("unity editor version not found in %s", projectVersionFilePath)
459-
}
460-
version := strings.TrimSpace(matches[1])
461-
if version == "" {
462-
return "", fmt.Errorf("unity editor version is empty in %s", projectVersionFilePath)
463-
}
464-
return version, nil
465-
}
466-
467445
func killUnityProcess(pid int) error {
468446
process, err := os.FindProcess(pid)
469447
if err != nil {

cli/dispatcher/internal/dispatcher/launch_deps.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,28 @@ import (
99
)
1010

1111
type launchDeps struct {
12+
now func() time.Time
1213
findRunningUnityProcess func(context.Context, string) (*unityprocess.UnityProcess, error)
1314
focusUnityProcess func(context.Context, int) error
1415
killUnityProcess func(int) error
1516
resolveUnityExecutablePath func(string) (string, error)
1617
waitForUnityProcessExit func(context.Context, string, int, time.Duration, time.Duration) error
1718
waitForUnityStartupMarker func(context.Context, string, time.Duration, time.Duration) error
19+
waitForFreshUnityLockfile func(context.Context, string, time.Time, time.Duration, time.Duration) error
1820
waitForToolReadiness func(context.Context, string, time.Duration) error
1921
probeProjectIpcFallback func(context.Context, string) error
2022
}
2123

2224
func defaultLaunchDeps() launchDeps {
2325
return launchDeps{
26+
now: time.Now,
2427
findRunningUnityProcess: unityprocess.FindRunningUnityProcess,
2528
focusUnityProcess: unityprocess.FocusUnityProcess,
2629
killUnityProcess: killUnityProcess,
2730
resolveUnityExecutablePath: resolveUnityExecutablePath,
2831
waitForUnityProcessExit: waitForUnityProcessExit,
2932
waitForUnityStartupMarker: waitForUnityStartupMarkerOrTimeout,
33+
waitForFreshUnityLockfile: waitForFreshUnityLockfile,
3034
waitForToolReadiness: clicore.WaitForToolReadinessWithTimeout,
3135
probeProjectIpcFallback: clicore.ProbeToolReadinessSequence,
3236
}

cli/dispatcher/internal/dispatcher/launch_ready.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,17 @@ func writeExistingLaunchReadyResponse(stdout io.Writer, stderr io.Writer, projec
7070
})
7171
}
7272

73+
func writeExistingV2LaunchOpenedResponse(stdout io.Writer, stderr io.Writer, projectRoot string, currentPid int) int {
74+
return writeLaunchResponse(stdout, stderr, launchReadyResponse{
75+
Success: true,
76+
Ready: true,
77+
AlreadyRunning: true,
78+
CurrentProcessId: &currentPid,
79+
ProjectRoot: projectRoot,
80+
Message: "Unity is already running for this V2 project. V2 server readiness was not checked.",
81+
})
82+
}
83+
7384
func writeLaunchedReadyResponse(
7485
stdout io.Writer,
7586
stderr io.Writer,
@@ -91,6 +102,25 @@ func writeLaunchedReadyResponse(
91102
})
92103
}
93104

105+
func writeLaunchedV2ProjectOpenedResponse(
106+
stdout io.Writer,
107+
stderr io.Writer,
108+
projectRoot string,
109+
previousPid *int,
110+
currentPid int,
111+
) int {
112+
return writeLaunchResponse(stdout, stderr, launchReadyResponse{
113+
Success: true,
114+
Ready: true,
115+
Launched: true,
116+
Restarted: previousPid != nil,
117+
PreviousProcessId: previousPid,
118+
CurrentProcessId: &currentPid,
119+
ProjectRoot: projectRoot,
120+
Message: "Unity started and opened the V2 project. V2 server readiness was not checked.",
121+
})
122+
}
123+
94124
func writeLaunchQuitResponse(
95125
stdout io.Writer,
96126
stderr io.Writer,

cli/dispatcher/internal/dispatcher/launch_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"errors"
88
"fmt"
9+
"io"
910
"os"
1011
"path/filepath"
1112
"runtime"
@@ -307,6 +308,34 @@ func TestRunLaunchWritesReadyResponseAfterToolReadiness(t *testing.T) {
307308
}
308309
}
309310

311+
// Verifies a V2 launch confirms only that Unity opened the project and never waits for the V3 named pipe.
312+
func TestRunLaunchForV2ProjectWaitsForFreshLockfileWithoutServerProbe(t *testing.T) {
313+
projectRoot := createLaunchTestProject(t)
314+
writeV2PackageManifest(t, projectRoot)
315+
writeV2PackageCachePackageJSON(t, projectRoot, "abc123", "2.2.0")
316+
deps := defaultLaunchDeps()
317+
deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { return nil, nil }
318+
deps.resolveUnityExecutablePath = func(string) (string, error) { return fakeUnityExecutablePath(t), nil }
319+
deps.waitForFreshUnityLockfile = func(context.Context, string, time.Time, time.Duration, time.Duration) error { return nil }
320+
deps.waitForToolReadiness = func(context.Context, string, time.Duration) error {
321+
t.Fatal("V2 launch must not wait for the V3 server")
322+
return nil
323+
}
324+
325+
var stdout bytes.Buffer
326+
code := runLaunchWithDeps(context.Background(), launchOptions{projectPath: projectRoot, editorVersion: "6000.0.0f1"}, projectRoot, &stdout, io.Discard, deps)
327+
if code != 0 {
328+
t.Fatalf("V2 launch exit code = %d", code)
329+
}
330+
response := decodeLaunchResponseFromOutput(t, stdout.String())
331+
if !response.Success || !response.Ready || response.ServerReady || response.ProjectIpcReady {
332+
t.Fatalf("V2 launch readiness flags = %#v", response)
333+
}
334+
if !strings.Contains(response.Message, "V2 server readiness was not checked") {
335+
t.Fatalf("V2 launch message = %q", response.Message)
336+
}
337+
}
338+
310339
func TestWaitForLaunchReadinessUsesLaunchTimeout(t *testing.T) {
311340
// Verifies launch gets a longer startup window without changing shared readiness defaults.
312341
deps := defaultLaunchDeps()
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package dispatcher
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
)
9+
10+
func windowsUnityExecutableCandidates(version string) []string {
11+
candidates := []string{}
12+
for _, base := range []string{os.Getenv("ProgramFiles"), os.Getenv("ProgramFiles(x86)"), os.Getenv("LOCALAPPDATA"), `C:\Program Files`} {
13+
if base != "" {
14+
candidates = append(candidates, filepath.Join(base, "Unity", "Hub", "Editor", version, "Editor", "Unity.exe"))
15+
}
16+
}
17+
return candidates
18+
}
19+
20+
func readUnityEditorVersion(projectRoot string) (string, error) {
21+
content, err := os.ReadFile(filepath.Join(projectRoot, projectVersionFilePath))
22+
if err != nil {
23+
return "", err
24+
}
25+
matches := editorVersionPattern.FindStringSubmatch(string(content))
26+
if len(matches) != 2 {
27+
return "", fmt.Errorf("unity editor version not found in %s", projectVersionFilePath)
28+
}
29+
version := strings.TrimSpace(matches[1])
30+
if version == "" {
31+
return "", fmt.Errorf("unity editor version is empty in %s", projectVersionFilePath)
32+
}
33+
return version, nil
34+
}

0 commit comments

Comments
 (0)