diff --git a/cli/common/ui/spinner.go b/cli/common/ui/spinner.go index 2b39b4be4f..775eb97dc0 100644 --- a/cli/common/ui/spinner.go +++ b/cli/common/ui/spinner.go @@ -17,14 +17,15 @@ const ( var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} type TerminalSpinner struct { - writer io.Writer - Enabled bool - message string - frame int - done chan struct{} - stopped chan struct{} - stopOnce sync.Once - mutex sync.Mutex + writer io.Writer + Enabled bool + message string + frame int + done chan struct{} + stopped chan struct{} + stopOnce sync.Once + mutex sync.Mutex + stoppedFlag bool } func NewToolSpinner(stderr io.Writer, showFeedback bool) *TerminalSpinner { @@ -77,6 +78,10 @@ func (spinner *TerminalSpinner) Update(message string) { } spinner.mutex.Lock() + if spinner.stoppedFlag { + spinner.mutex.Unlock() + return + } spinner.message = message spinner.mutex.Unlock() spinner.render() @@ -92,6 +97,7 @@ func (spinner *TerminalSpinner) Stop() { <-spinner.stopped spinner.mutex.Lock() defer spinner.mutex.Unlock() + spinner.stoppedFlag = true _, _ = fmt.Fprint(spinner.writer, "\r\x1b[K\n") }) } @@ -114,6 +120,9 @@ func (spinner *TerminalSpinner) run() { func (spinner *TerminalSpinner) render() { spinner.mutex.Lock() defer spinner.mutex.Unlock() + if spinner.stoppedFlag { + return + } frame := spinnerFrames[spinner.frame] spinner.frame = (spinner.frame + 1) % len(spinnerFrames) diff --git a/cli/common/ui/spinner_test.go b/cli/common/ui/spinner_test.go index 39ba082c95..face7ac28d 100644 --- a/cli/common/ui/spinner_test.go +++ b/cli/common/ui/spinner_test.go @@ -89,6 +89,24 @@ func TestSpinnerProgressFuncMapsConnectionEventsToExecutingMessage(t *testing.T) } } +func TestSpinnerUpdateAfterStopWritesNothing(t *testing.T) { + // Verifies Update after Stop does not render leftover TTY frames. + var stderr bytes.Buffer + + spinner := newSpinner(&stderr, true, "Executing compile...") + spinner.Stop() + afterStop := stderr.Len() + + spinner.Update("Warming execute-dynamic-code after compile...") + + if stderr.Len() != afterStop { + t.Fatalf("Update after Stop wrote output: %q", stderr.String()[afterStop:]) + } + if strings.Contains(stderr.String(), "Warming execute-dynamic-code after compile...") { + t.Fatalf("stopped spinner rendered an Update message: %q", stderr.String()) + } +} + func TestNewToolSpinnerRespectsFeedbackFlag(t *testing.T) { // Verifies callers can disable tool spinner feedback for hot paths. var stderr bytes.Buffer diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index a5d1681bb0..9b9f26d01a 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "0e716971c0854f6f73596d38f09343e4c97345f2" + "sharedInputsHash": "37b3868ecf195cac6405ed46a758ffa87cdc84db" } diff --git a/cli/project-runner/internal/projectrunner/compile_attach.go b/cli/project-runner/internal/projectrunner/compile_attach.go index f92ea47356..232e911036 100644 --- a/cli/project-runner/internal/projectrunner/compile_attach.go +++ b/cli/project-runner/internal/projectrunner/compile_attach.go @@ -141,6 +141,7 @@ func attachWaitForPendingCompile( pollInterval = compileWaitPollInterval } waitStartedAt := time.Now() + bindCompileWaitInterimReporter(stderr, spinner, &deps) result, outcome, lastStatus, waitErr := waitForAttachedCompileCompletion(ctx, compileCompletionOptions{ connection: connection, requestID: record.RequestID, @@ -204,6 +205,7 @@ func waitForAttachedCompileCompletion( lastObservationKey := "" logCompileStatusPollStart(options, startedAt, deadline) + interim := newCompileWaitInterimState(compileWaitNow(deps)) ticker := time.NewTicker(options.pollInterval) defer ticker.Stop() @@ -235,6 +237,7 @@ func waitForAttachedCompileCompletion( } } logCompileStatusPollObservedIfChanged(options, startedAt, attempts, status, err, &lastObservationKey) + observeCompileWaitInterim(&interim, deps, status, err) select { case <-ctx.Done(): diff --git a/cli/project-runner/internal/projectrunner/compile_attach_test.go b/cli/project-runner/internal/projectrunner/compile_attach_test.go index 7860c81f21..68d5d08670 100644 --- a/cli/project-runner/internal/projectrunner/compile_attach_test.go +++ b/cli/project-runner/internal/projectrunner/compile_attach_test.go @@ -152,6 +152,48 @@ func TestRunCompileAttachWaitsForInFlightCompileAndClearsRecord(t *testing.T) { } } +// Verifies attachWaitForPendingCompile binds the interim reporter so stderr +// gets the progress line without injecting reportInterim on the caller. +func TestRunCompileAttachWaitReportsInterimProgressLine(t *testing.T) { + projectRoot := t.TempDir() + requestID := "compile_attach_interim_route" + if err := writeCompilePendingRecord(projectRoot, compilePendingRecord{ + RequestID: requestID, + TimedOutAtUtc: time.Now().UTC().Add(-time.Minute), + }); err != nil { + t.Fatalf("write pending record failed: %v", err) + } + + start := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC) + now := start + queryCalls := 0 + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + queryCalls++ + if queryCalls == 1 { + return compileStatusResponse{Ready: false, IsCompiling: true}, nil + } + now = now.Add(60 * time.Second) + return compileStatusResponse{Ready: false, IsCompiling: true}, nil + }) + deps.now = func() time.Time { return now } + deps.interimReportInterval = 60 * time.Second + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{Network: "tcp", Address: "127.0.0.1:1"}, + ProjectRoot: projectRoot, + } + params := map[string]any{compileWaitTimeoutParam: 1} + var stdout, stderr bytes.Buffer + + code := runCompileWithDomainReloadWaitWithDeps(context.Background(), connection, params, &stdout, &stderr, deps) + if code != 1 { + t.Fatalf("expected attach timeout exit 1: code=%d stderr=%s", code, stderr.String()) + } + expected := "compile: still waiting for Unity (elapsed 60s; last status: is_compiling=true, is_domain_reload_in_progress=false)." + if !strings.Contains(stderr.String(), expected) { + t.Fatalf("attach wait must print the interim progress line via bindCompileWaitInterimReporter:\n%s", stderr.String()) + } +} + // Verifies a completed pending compile returns the stored result without a new request. func TestRunCompileAttachReturnsStoredResultAndClearsRecord(t *testing.T) { enableCliVibeLog(t) diff --git a/cli/project-runner/internal/projectrunner/compile_wait.go b/cli/project-runner/internal/projectrunner/compile_wait.go index 530acfb4b0..0405b0d2b2 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait.go +++ b/cli/project-runner/internal/projectrunner/compile_wait.go @@ -183,6 +183,7 @@ func waitForCompileCompletionWithDeps( lastObservationKey := "" logCompileStatusPollStart(options, startedAt, deadline) + interim := newCompileWaitInterimState(compileWaitNow(deps)) ticker := time.NewTicker(options.pollInterval) defer ticker.Stop() @@ -205,6 +206,7 @@ func waitForCompileCompletionWithDeps( observedStatus = true } logCompileStatusPollObservedIfChanged(options, startedAt, attempts, status, err, &lastObservationKey) + observeCompileWaitInterim(&interim, deps, status, err) select { case <-ctx.Done(): diff --git a/cli/project-runner/internal/projectrunner/compile_wait_deps.go b/cli/project-runner/internal/projectrunner/compile_wait_deps.go index 2e0243e060..770f645810 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait_deps.go +++ b/cli/project-runner/internal/projectrunner/compile_wait_deps.go @@ -22,6 +22,9 @@ type compileWaitDeps struct { attachProbeTimeout time.Duration attachProbeInterval time.Duration attachWaitPollInterval time.Duration + now func() time.Time + interimReportInterval time.Duration + reportInterim compileWaitInterimReporter } func compileSendOrDefault(deps compileWaitDeps) compileSendFunc { diff --git a/cli/project-runner/internal/projectrunner/compile_wait_interim.go b/cli/project-runner/internal/projectrunner/compile_wait_interim.go new file mode 100644 index 0000000000..4ea7d9e64d --- /dev/null +++ b/cli/project-runner/internal/projectrunner/compile_wait_interim.go @@ -0,0 +1,131 @@ +package projectrunner + +import ( + "fmt" + "io" + "time" + + "github.com/hatayama/unity-cli-loop/common/ui" +) + +const ( + compileWaitInterimIntervalDefault = 60 * time.Second + compileWaitInterimSilentThreshold = 30 * time.Second +) + +type compileWaitInterimReporter func(string) + +type compileWaitInterimState struct { + waitStartedAt time.Time + lastSuccessAt time.Time + lastSuccessStatus compileStatusResponse + hasSuccess bool + lastReportAt time.Time + hasReported bool +} + +func newCompileWaitInterimState(waitStartedAt time.Time) compileWaitInterimState { + return compileWaitInterimState{waitStartedAt: waitStartedAt} +} + +func (state *compileWaitInterimState) noteSuccessfulPoll(completedAt time.Time, status compileStatusResponse) { + state.lastSuccessAt = completedAt + state.lastSuccessStatus = status + state.hasSuccess = true +} + +func (state *compileWaitInterimState) lineIfDue(now time.Time, interval time.Duration) (string, bool) { + if interval <= 0 { + interval = compileWaitInterimIntervalDefault + } + silentFor := now.Sub(state.silentAnchor()) + useSilent := silentFor >= compileWaitInterimSilentThreshold + if !state.isDue(now, interval, useSilent, silentFor) { + return "", false + } + state.hasReported = true + state.lastReportAt = now + if useSilent { + return formatCompileWaitSilentLine(silentFor), true + } + return formatCompileWaitProgressLine(now.Sub(state.waitStartedAt), state.lastSuccessStatus), true +} + +func (state *compileWaitInterimState) silentAnchor() time.Time { + if state.hasSuccess { + return state.lastSuccessAt + } + return state.waitStartedAt +} + +func (state *compileWaitInterimState) isDue( + now time.Time, + interval time.Duration, + useSilent bool, + silentFor time.Duration, +) bool { + if state.hasReported { + return now.Sub(state.lastReportAt) >= interval + } + if useSilent { + return silentFor >= compileWaitInterimSilentThreshold + } + if !state.hasSuccess { + return false + } + return now.Sub(state.waitStartedAt) >= interval +} + +func formatCompileWaitProgressLine(elapsed time.Duration, status compileStatusResponse) string { + return fmt.Sprintf( + "compile: still waiting for Unity (elapsed %ds; last status: is_compiling=%t, is_domain_reload_in_progress=%t).", + int(elapsed/time.Second), + status.IsCompiling, + status.IsDomainReloadInProgress, + ) +} + +func formatCompileWaitSilentLine(silentFor time.Duration) string { + return fmt.Sprintf( + "compile: Unity has not answered status polls for %ds. The Editor may be blocked by a modal dialog or stuck; if this persists, restart Unity with 'uloop launch -r'.", + int(silentFor/time.Second), + ) +} + +func compileWaitNow(deps compileWaitDeps) time.Time { + if deps.now != nil { + return deps.now() + } + return time.Now() +} + +func observeCompileWaitInterim( + state *compileWaitInterimState, + deps compileWaitDeps, + status compileStatusResponse, + err error, +) { + now := compileWaitNow(deps) + if err == nil { + state.noteSuccessfulPoll(now, status) + } + if deps.reportInterim == nil { + return + } + line, due := state.lineIfDue(now, deps.interimReportInterval) + if !due { + return + } + deps.reportInterim(line) +} + +func bindCompileWaitInterimReporter(stderr io.Writer, spinner *ui.TerminalSpinner, deps *compileWaitDeps) { + stopped := false + deps.reportInterim = func(line string) { + if !stopped { + spinner.Stop() + stopped = true + } + _, _ = fmt.Fprintln(stderr, line) + } +} diff --git a/cli/project-runner/internal/projectrunner/compile_wait_interim_test.go b/cli/project-runner/internal/projectrunner/compile_wait_interim_test.go new file mode 100644 index 0000000000..71793985ac --- /dev/null +++ b/cli/project-runner/internal/projectrunner/compile_wait_interim_test.go @@ -0,0 +1,234 @@ +package projectrunner + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + clierrors "github.com/hatayama/unity-cli-loop/common/errors" + "github.com/hatayama/unity-cli-loop/common/unityipc" +) + +func TestCompileWaitInterimReportsProgressLineAtSixtySeconds(t *testing.T) { + // Verifies one progress line at 60s of successful polls, and nothing before that. + start := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC) + state := newCompileWaitInterimState(start) + status := compileStatusResponse{IsCompiling: true, IsDomainReloadInProgress: false} + + state.noteSuccessfulPoll(start.Add(59*time.Second), status) + line, due := state.lineIfDue(start.Add(59*time.Second), compileWaitInterimIntervalDefault) + if due { + t.Fatalf("must not report before 60s: %q", line) + } + + state.noteSuccessfulPoll(start.Add(60*time.Second), status) + line, due = state.lineIfDue(start.Add(60*time.Second), compileWaitInterimIntervalDefault) + if !due { + t.Fatal("expected a progress line at 60s") + } + expected := "compile: still waiting for Unity (elapsed 60s; last status: is_compiling=true, is_domain_reload_in_progress=false)." + if line != expected { + t.Fatalf("progress line mismatch:\n got: %q\nwant: %q", line, expected) + } + + line, due = state.lineIfDue(start.Add(119*time.Second), compileWaitInterimIntervalDefault) + if due { + t.Fatalf("must report progress only once before the next 60s period: %q", line) + } +} + +func TestCompileWaitInterimSwitchesToSilentLineAfterThirtySecondsWithoutSuccess(t *testing.T) { + // Verifies a successful poll followed by 30s of silence switches to the silent line. + start := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC) + state := newCompileWaitInterimState(start) + state.noteSuccessfulPoll(start, compileStatusResponse{IsCompiling: true}) + + line, due := state.lineIfDue(start.Add(29*time.Second), compileWaitInterimIntervalDefault) + if due { + t.Fatalf("must not switch before 30s of silence: %q", line) + } + + line, due = state.lineIfDue(start.Add(30*time.Second), compileWaitInterimIntervalDefault) + if !due { + t.Fatal("expected a silent line after 30s without a successful poll") + } + expected := "compile: Unity has not answered status polls for 30s. The Editor may be blocked by a modal dialog or stuck; if this persists, restart Unity with 'uloop launch -r'." + if line != expected { + t.Fatalf("silent line mismatch:\n got: %q\nwant: %q", line, expected) + } +} + +func TestCompileWaitInterimReportsSilentLineWhenNoSuccessfulPolls(t *testing.T) { + // Verifies zero successful polls still use wait-start as the silent anchor. + start := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC) + state := newCompileWaitInterimState(start) + + line, due := state.lineIfDue(start.Add(29*time.Second), compileWaitInterimIntervalDefault) + if due { + t.Fatalf("must not report silent before 30s: %q", line) + } + + line, due = state.lineIfDue(start.Add(30*time.Second), compileWaitInterimIntervalDefault) + if !due { + t.Fatal("expected a silent line at 30s with zero successful polls") + } + expected := "compile: Unity has not answered status polls for 30s. The Editor may be blocked by a modal dialog or stuck; if this persists, restart Unity with 'uloop launch -r'." + if line != expected { + t.Fatalf("silent line mismatch:\n got: %q\nwant: %q", line, expected) + } +} + +func TestWaitForCompileCompletionReportsInterimProgressLine(t *testing.T) { + // Verifies the fresh wait loop emits the progress line after injected 60s. + connection := compileWaitTestConnection(t) + start := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC) + now := start + var lines []string + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + now = now.Add(60 * time.Second) + return compileStatusResponse{Ready: false, IsCompiling: true, IsDomainReloadInProgress: true}, nil + }) + deps.now = func() time.Time { return now } + deps.interimReportInterval = 60 * time.Second + deps.reportInterim = func(line string) { lines = append(lines, line) } + + _, completed, _, err := waitForCompileCompletionWithDeps(context.Background(), compileCompletionOptions{ + connection: connection, + requestID: "compile_interim_progress", + timeout: 40 * time.Millisecond, + pollInterval: 5 * time.Millisecond, + }, deps) + if err != nil { + t.Fatalf("waitForCompileCompletion failed: %v", err) + } + if completed { + t.Fatal("expected timeout so interim can report") + } + expected := "compile: still waiting for Unity (elapsed 60s; last status: is_compiling=true, is_domain_reload_in_progress=true)." + if len(lines) == 0 || lines[0] != expected { + t.Fatalf("fresh wait progress line mismatch: %#v", lines) + } +} + +func TestWaitForAttachedCompileCompletionReportsInterimSilentLine(t *testing.T) { + // Verifies the attach wait loop emits the same silent line after 30s without success. + connection := compileWaitTestConnection(t) + start := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC) + now := start + var lines []string + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + now = now.Add(30 * time.Second) + return compileStatusResponse{}, errors.New("status poll failed") + }) + deps.now = func() time.Time { return now } + deps.interimReportInterval = 60 * time.Second + deps.reportInterim = func(line string) { lines = append(lines, line) } + + _, outcome, _, err := waitForAttachedCompileCompletion(context.Background(), compileCompletionOptions{ + connection: connection, + requestID: "compile_attach_interim_silent", + timeout: 40 * time.Millisecond, + pollInterval: 5 * time.Millisecond, + }, deps) + if err != nil { + t.Fatalf("waitForAttachedCompileCompletion failed: %v", err) + } + if outcome != attachWaitTimedOut { + t.Fatalf("expected attach timeout: %v", outcome) + } + expected := "compile: Unity has not answered status polls for 30s. The Editor may be blocked by a modal dialog or stuck; if this persists, restart Unity with 'uloop launch -r'." + if len(lines) == 0 || lines[0] != expected { + t.Fatalf("attach wait silent line mismatch: %#v", lines) + } +} + +func TestRunCompileTimeoutAfterInterimKeepsStdoutEmptyAndPrefixesStderr(t *testing.T) { + // Verifies timeout stderr is interim lines then the JSON envelope, and stdout stays empty. + start := time.Date(2026, 8, 21, 0, 0, 0, 0, time.UTC) + now := start + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + now = now.Add(60 * time.Second) + return compileStatusResponse{Ready: false, IsCompiling: true}, nil + }) + deps.now = func() time.Time { return now } + deps.interimReportInterval = 60 * time.Second + deps.sendCompile = func( + context.Context, + unityipc.Connection, + string, + map[string]any, + unityipc.ProgressFunc, + time.Duration, + ) (unityipc.UnitySendOutcome, error) { + return unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, nil + } + connection := compileWaitTestConnection(t) + params := map[string]any{compileWaitTimeoutParam: 1} + var stdout, stderr bytes.Buffer + + code := runCompileWithDomainReloadWaitWithDeps(context.Background(), connection, params, &stdout, &stderr, deps) + if code != 1 { + t.Fatalf("expected timeout exit 1: code=%d stderr=%s", code, stderr.String()) + } + if stdout.Len() != 0 { + t.Fatalf("timeout stdout must stay empty: %s", stdout.String()) + } + stderrText := stderr.String() + progressLine := "compile: still waiting for Unity (elapsed 60s; last status: is_compiling=true, is_domain_reload_in_progress=false).\n" + if !strings.HasPrefix(stderrText, progressLine) { + t.Fatalf("stderr must start with the interim line:\n%s", stderrText) + } + jsonStart := bytes.IndexByte(stderr.Bytes(), '{') + if jsonStart < 0 { + t.Fatalf("stderr has no JSON envelope: %s", stderrText) + } + var envelope clierrors.CLIErrorEnvelope + if err := json.Unmarshal(stderr.Bytes()[jsonStart:], &envelope); err != nil { + t.Fatalf("stderr JSON envelope mismatch: %v\n%s", err, stderrText) + } + if envelope.Error.ErrorCode != clierrors.ErrorCodeCompileWaitTimeout { + t.Fatalf("error code mismatch: %#v", envelope.Error.ErrorCode) + } +} + +func TestRunCompileSuccessUnderSixtySecondsWritesOnlyStdoutJSON(t *testing.T) { + // Verifies a compile that finishes before 60s keeps stdout as a single JSON object and adds no interim stderr. + deps := compileWaitTestDeps(func(context.Context, unityipc.Connection, string) (compileStatusResponse, error) { + return compileStatusResponse{ + Ready: true, + HasResult: true, + Result: json.RawMessage(`{"Success":false,"ErrorCount":0}`), + }, nil + }) + deps.sendCompile = func( + context.Context, + unityipc.Connection, + string, + map[string]any, + unityipc.ProgressFunc, + time.Duration, + ) (unityipc.UnitySendOutcome, error) { + return unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, nil + } + connection := compileWaitTestConnection(t) + var stdout, stderr bytes.Buffer + + code := runCompileWithDomainReloadWaitWithDeps(context.Background(), connection, map[string]any{}, &stdout, &stderr, deps) + if code != 1 { + t.Fatalf("expected completed compile envelope exit 1: code=%d stderr=%s", code, stderr.String()) + } + expectedStdout := "{\n \"Success\": false,\n \"ErrorCount\": 0\n}\n" + if stdout.String() != expectedStdout { + t.Fatalf("stdout must be the single compile JSON:\n got: %q\nwant: %q", stdout.String(), expectedStdout) + } + if strings.Contains(stderr.String(), "compile: still waiting for Unity") { + t.Fatalf("success under 60s must not add a progress line: %s", stderr.String()) + } + if strings.Contains(stderr.String(), "compile: Unity has not answered status polls") { + t.Fatalf("success under 60s must not add a silent line: %s", stderr.String()) + } +} diff --git a/cli/project-runner/internal/projectrunner/compile_wait_test.go b/cli/project-runner/internal/projectrunner/compile_wait_test.go index 934e919ea2..f107970df3 100644 --- a/cli/project-runner/internal/projectrunner/compile_wait_test.go +++ b/cli/project-runner/internal/projectrunner/compile_wait_test.go @@ -835,6 +835,7 @@ func TestCompileWaitTimeoutError(t *testing.T) { "Unity keeps compiling and refuses other commands with UNITY_SERVER_BUSY until it finishes. Re-run `uloop compile`: it will reattach to the in-flight compile and wait for its result instead of starting a new one. The result stays retrievable for roughly 18 more minutes.", clierrors.ApiUpdateConsentModalNextAction, "Only if Unity does not respond to any command, restart it with `uloop launch -r`.", + "If repeated waits keep showing is_compiling=true with no progress, the Editor's compile pipeline may be stalled (for example by a modal dialog); restart Unity with 'uloop launch -r' and rerun 'uloop compile'.", } if len(cliErr.NextActions) != len(expectedActions) { t.Fatalf("next actions mismatch: %#v", cliErr.NextActions) diff --git a/cli/project-runner/internal/projectrunner/execution_errors.go b/cli/project-runner/internal/projectrunner/execution_errors.go index e00b72b6f8..a28a9fdd45 100644 --- a/cli/project-runner/internal/projectrunner/execution_errors.go +++ b/cli/project-runner/internal/projectrunner/execution_errors.go @@ -69,5 +69,6 @@ func compileWaitTimeoutNextActions(retentionRemaining time.Duration) []string { reattachAction, clierrors.ApiUpdateConsentModalNextAction, "Only if Unity does not respond to any command, restart it with `uloop launch -r`.", + "If repeated waits keep showing is_compiling=true with no progress, the Editor's compile pipeline may be stalled (for example by a modal dialog); restart Unity with 'uloop launch -r' and rerun 'uloop compile'.", } } diff --git a/cli/project-runner/internal/projectrunner/run.go b/cli/project-runner/internal/projectrunner/run.go index f9559d3340..912511b486 100644 --- a/cli/project-runner/internal/projectrunner/run.go +++ b/cli/project-runner/internal/projectrunner/run.go @@ -241,6 +241,7 @@ func runFreshCompileWithDomainReloadWaitWithDeps( spinner.Update("Waiting for domain reload to complete...") waitStartedAt := time.Now() + bindCompileWaitInterimReporter(stderr, spinner, &compileWait) result, completed, lastStatus, waitErr := waitForCompileCompletionWithDeps(ctx, compileCompletionOptions{ connection: connection, requestID: requestID, diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 70e4d84f6a..52e190d7f3 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "c61b52ee050d0c742d32cfa2dddbce455e631aa1" + "sharedInputsHash": "b3ae61bb3dd3d8285919d640eb1f431bede2c2b6" }