Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 1 addition & 18 deletions cli/project-runner/internal/projectrunner/connection_retry.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,16 @@ const (
focusRestoreTimeout = 2 * time.Second
serverConnectionRetryDefaultTimeout = 10 * time.Second
serverConnectionRetryDefaultPoll = 1 * time.Second
defaultBusyFocusStallThreshold = 5 * time.Second
)

type connectionRetryDeps struct {
findRunningUnityProcess func(context.Context, string) (*unityprocess.UnityProcess, error)
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
}

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down
43 changes: 14 additions & 29 deletions cli/project-runner/internal/projectrunner/connection_retry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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) {
Expand Down Expand Up @@ -1319,18 +1305,17 @@ 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")
}

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) {
Expand Down Expand Up @@ -1390,11 +1375,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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading