From 45edc2ec9c16865456d32278874e434031762f23 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 01:06:12 +0900 Subject: [PATCH 1/3] Expect persistent busy answers never to focus the Editor The busy-stall focus rescued a backgrounded Editor that macOS might have suspended. ADR 0012 has the running command hold the activity, so a busy Editor is a working one and bringing it to the front only interrupts the user. This test is Red until the hook is removed. --- .../projectrunner/connection_retry_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/cli/project-runner/internal/projectrunner/connection_retry_test.go b/cli/project-runner/internal/projectrunner/connection_retry_test.go index 91923a63e..b95d92093 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_test.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_test.go @@ -1319,10 +1319,10 @@ func TestSendWithTransientConnectionRetryReturnsBusyAfterRetryWindow(t *testing. } } -// TDD repro for B-7a: before busy_stall focus rescue, persistent server_busy never called -// focusUnityProcess (focusCallCount stayed 0). This assertion was Red on pre-fix -// connection_retry.go and turns Green after the busy stall threshold hook. -func TestSendWithTransientConnectionRetryFocusesOnceAfterPersistentBusy(t *testing.T) { +// Verifies persistent server_busy answers never bring the Editor to the front: the request +// never ran, and the running command holds the activity (ADR 0012), so there is nothing to +// rescue. +func TestSendWithTransientConnectionRetryNeverFocusesWhileBusy(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("TCP endpoint injection is only used by this non-Windows client test") } @@ -1390,11 +1390,11 @@ func TestSendWithTransientConnectionRetryFocusesOnceAfterPersistentBusy(t *testi if err == nil { t.Fatal("expected busy error after retry window") } - if focusCallCount != 1 { - t.Fatalf("expected one busy-stall focus attempt, got %d", focusCallCount) + if focusCallCount != 0 { + t.Fatalf("expected no focus attempt while Unity answered busy, got %d", focusCallCount) } - if restoreCallCount != 1 { - t.Fatalf("expected focus restore after busy retry exit, got %d", restoreCallCount) + if restoreCallCount != 0 { + t.Fatalf("expected no focus restore while Unity answered busy, got %d", restoreCallCount) } } From 872a9eb74e39d9f4883f973b0a6adce08cb9a3ae Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 01:06:54 +0900 Subject: [PATCH 2/3] Stop focusing the Editor when busy answers last five seconds The busy-stall focus brought a backgrounded Editor to the front in case macOS had suspended it and the running command could not finish. The running command now holds a macOS activity (ADR 0012), so a busy Editor is a working one. The bounded resend and the BUSY error at the end of the window stay: busy still means the request never ran. --- .../projectrunner/connection_retry.go | 19 +------------ .../projectrunner/connection_retry_test.go | 27 +++++-------------- .../projectrunner/hot_reload_busy_wait.go | 3 +-- 3 files changed, 8 insertions(+), 41 deletions(-) diff --git a/cli/project-runner/internal/projectrunner/connection_retry.go b/cli/project-runner/internal/projectrunner/connection_retry.go index 4d5929e6e..8d42c908d 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry.go +++ b/cli/project-runner/internal/projectrunner/connection_retry.go @@ -18,7 +18,6 @@ const ( focusRestoreTimeout = 2 * time.Second serverConnectionRetryDefaultTimeout = 10 * time.Second serverConnectionRetryDefaultPoll = 1 * time.Second - defaultBusyFocusStallThreshold = 5 * time.Second ) type connectionRetryDeps struct { @@ -26,11 +25,9 @@ type connectionRetryDeps struct { focusUnityProcess func(context.Context, int) (unityprocess.RestoreFocusFunc, error) retryTimeout time.Duration retryPoll time.Duration - busyFocusStallThreshold time.Duration // returnBusyWithoutRetry hands the first busy answer back instead of resending every // retryPoll. Hot reload sets it because it waits for the busy slot to free on the Editor - // status: resending here would only delay that wait, and after busyFocusStallThreshold the - // busy-stall focus would bring the Editor to the front. + // status: resending here would only delay that wait. returnBusyWithoutRetry bool } @@ -51,7 +48,6 @@ const ( focusReasonMainThreadStall connectionRetryFocusReason = "main_thread_stall" focusReasonHeartbeatSilenceTimeout connectionRetryFocusReason = "heartbeat_silence_timeout" focusReasonFinalResponseTimeout connectionRetryFocusReason = "final_response_timeout" - focusReasonBusyStall connectionRetryFocusReason = "busy_stall" ) // Why: domain reload on large projects can keep the IPC endpoint down well past the @@ -66,13 +62,6 @@ func unityAliveRetryWindow(deps connectionRetryDeps) time.Duration { return deps.retryTimeout * serverConnectionRetryUnityAliveFactor } -func busyFocusStallThresholdFor(deps connectionRetryDeps) time.Duration { - if deps.busyFocusStallThreshold > 0 { - return deps.busyFocusStallThreshold - } - return defaultBusyFocusStallThreshold -} - type connectionRetryFocusController struct { connection unityipc.Connection method string @@ -247,7 +236,6 @@ func sendWithTransientConnectionRetryWithDeps( var lastOutcome unityipc.UnitySendOutcome var lastErr error - busySequenceStartedAt := time.Time{} focusController := newConnectionRetryFocusController(connection, method, deps) defer func() { restoreContext, cancel := context.WithTimeout(context.Background(), focusRestoreTimeout) @@ -273,11 +261,6 @@ func sendWithTransientConnectionRetryWithDeps( } // Busy means the request was never executed, so a bounded retry is safe and // usually absorbs back-to-back tool calls without bothering the caller. - if busySequenceStartedAt.IsZero() { - busySequenceStartedAt = time.Now() - } else if time.Since(busySequenceStartedAt) >= busyFocusStallThresholdFor(deps) { - focusController.tryFocus(ctx, focusReasonBusyStall, err) - } lastOutcome = outcome lastErr = err if finished, finalOutcome, finalErr := finishBusyRetry( diff --git a/cli/project-runner/internal/projectrunner/connection_retry_test.go b/cli/project-runner/internal/projectrunner/connection_retry_test.go index b95d92093..14d18a053 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_test.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_test.go @@ -24,19 +24,6 @@ import ( "github.com/hatayama/unity-cli-loop/common/unityprocess" ) -// Verifies the default busy-stall focus threshold fires before the bounded busy retry window ends. -func TestDefaultBusyFocusStallThresholdFitsWithinBusyRetryWindow(t *testing.T) { - deps := defaultConnectionRetryDeps() - threshold := busyFocusStallThresholdFor(deps) - if threshold >= deps.retryTimeout { - t.Fatalf( - "busy focus stall threshold must stay below the busy retry window: threshold=%s window=%s", - threshold, - deps.retryTimeout, - ) - } -} - // Verifies connection-retry focus rescue bounds the focus external command with a deadline. func TestConnectionRetryFocusControllerBoundsFocusContext(t *testing.T) { var receivedContext context.Context @@ -50,7 +37,7 @@ func TestConnectionRetryFocusControllerBoundsFocusContext(t *testing.T) { "get-logs", deps, ) - controller.tryFocusProcess(context.Background(), 123, focusReasonBusyStall, errors.New("busy")) + controller.tryFocusProcess(context.Background(), 123, focusReasonPreAcceptTimeout, errors.New("busy")) if receivedContext == nil { t.Fatal("expected focus attempt context") @@ -228,7 +215,7 @@ func TestConnectionRetryFocusControllerLogsRestoreSuccessWithAttemptCorrelation( deps, ) - controller.tryFocusProcess(context.Background(), 123, focusReasonBusyStall, errors.New("busy")) + controller.tryFocusProcess(context.Background(), 123, focusReasonPreAcceptTimeout, errors.New("busy")) controller.restore(context.Background()) if restoreCallCount != 1 { @@ -253,7 +240,7 @@ func TestConnectionRetryFocusControllerLogsRestoreSuccessWithAttemptCorrelation( for _, expected := range []string{ `"command":"get-logs"`, `"pid":123`, - `"reason":"busy_stall"`, + `"reason":"pre_accept_timeout"`, } { if !strings.Contains(logContent, expected) { t.Fatalf("CLI Vibe log missing %q:\n%s", expected, logContent) @@ -360,7 +347,7 @@ func TestConnectionRetryFocusControllerLogsMissingRestorerAtFocusTime(t *testing deps, ) - controller.tryFocusProcess(context.Background(), 123, focusReasonBusyStall, errors.New("busy")) + controller.tryFocusProcess(context.Background(), 123, focusReasonPreAcceptTimeout, errors.New("busy")) controller.restore(context.Background()) logContent := readOnlyCliVibeLog(t, projectRoot) @@ -1080,8 +1067,8 @@ func TestSendWithTransientConnectionRetryRetriesBusyResponses(t *testing.T) { } } -// Verifies that returnBusyWithoutRetry hands the first busy answer back at once: no resend -// and no busy-stall focus, because hot reload waits for the Editor on its status instead. +// Verifies that returnBusyWithoutRetry hands the first busy answer back at once without a +// resend, because hot reload waits for the Editor on its status instead. func TestSendWithTransientConnectionRetryReturnsTheFirstBusyAnswerWhenAsked(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("TCP endpoint injection is only used by this non-Windows client test") @@ -1090,7 +1077,6 @@ func TestSendWithTransientConnectionRetryReturnsTheFirstBusyAnswerWhenAsked(t *t deps := defaultConnectionRetryDeps() deps.returnBusyWithoutRetry = true deps.retryPoll = 5 * time.Millisecond - deps.busyFocusStallThreshold = time.Nanosecond processLookups := 0 focusCalls := 0 deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { @@ -1330,7 +1316,6 @@ func TestSendWithTransientConnectionRetryNeverFocusesWhileBusy(t *testing.T) { deps := defaultConnectionRetryDeps() deps.retryTimeout = 500 * time.Millisecond deps.retryPoll = 5 * time.Millisecond - deps.busyFocusStallThreshold = 30 * time.Millisecond focusCallCount := 0 restoreCallCount := 0 deps.findRunningUnityProcess = func(context.Context, string) (*clicore.UnityProcess, error) { diff --git a/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go b/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go index 154d59626..5a41e201c 100644 --- a/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go +++ b/cli/project-runner/internal/projectrunner/hot_reload_busy_wait.go @@ -28,8 +28,7 @@ const ( ) // hotReloadBusyWaitSendDeps sends without the bounded busy retry. Why: hot reload waits for the -// Editor on its status instead of resending every second, which would also bring the Editor to the -// front after the busy-stall threshold. +// Editor on its status instead of resending every second. func hotReloadBusyWaitSendDeps() connectionRetryDeps { deps := defaultConnectionRetryDeps() deps.returnBusyWithoutRetry = true From 7f801614d59de791ceaa7cb3e11807e77109f291 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 8 Oct 2026 01:07:09 +0900 Subject: [PATCH 3/3] Record in ADR 0012 that busy answers no longer focus the Editor --- docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md b/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md index 1817d2a0b..7b9d9744b 100644 --- a/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md +++ b/docs/adr/0012-hold-a-macos-activity-while-a-command-runs.md @@ -69,6 +69,7 @@ retains it before its own autorelease pool drains and releases it after `endActi - A Begin and End pair costs about 4 µs inside the Editor (median of 100 pairs; the slowest took 0.11 ms). - `uloop compile` no longer brings the Editor to the front when compilation has not started within ten seconds. That rescue (#2341) existed for this suppression; with the activity held, a compile that has not started is a compile the Editor has not reached yet, and bringing it to the front only moves the user's windows. +- A request that Unity keeps answering `server_busy` no longer brings the Editor to the front after five seconds either. That rescue (B-7a) also existed for this suppression; with the activity held by the running command, a busy Editor is a working Editor. The bounded resend itself stays: busy means the request never ran. ## Reversal condition