From e89cfc5b02c4a72dcb2308fc213d751c9bed1971 Mon Sep 17 00:00:00 2001 From: masamichi Date: Tue, 16 Jun 2026 11:04:25 +0900 Subject: [PATCH 1/2] Focus Unity during slow response recovery Focus recovery previously only ran after undispatched connection failures, so accepted-but-stalled Unity requests never triggered the Windows focus warmup path. - Add typed main-thread stall reporting from IPC heartbeats. - Focus once for pre-accept timeouts, heartbeat stalls, heartbeat silence, and abnormal final response timeouts while skipping server_busy and intentional compile polling timeouts. - Restore Windows foreground focus with AttachThreadInput after recoverable focus attempts, preserve maximized windows, and leave Unity visible after terminal timeout failures. --- cli/internal/cli/connection_retry.go | 161 ++++++++++++++-- cli/internal/cli/connection_retry_test.go | 172 ++++++++++++++++++ cli/internal/cli/focus.go | 45 ++++- cli/internal/cli/focus_test.go | 27 +++ cli/internal/unityipc/client.go | 17 +- .../unityipc/client_heartbeat_test.go | 27 +++ 6 files changed, 421 insertions(+), 28 deletions(-) diff --git a/cli/internal/cli/connection_retry.go b/cli/internal/cli/connection_retry.go index e16232d0e1..0ea0207a13 100644 --- a/cli/internal/cli/connection_retry.go +++ b/cli/internal/cli/connection_retry.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/hatayama/unity-cli-loop/cli/internal/project" @@ -20,6 +21,16 @@ var ( const focusRestoreTimeout = 2 * time.Second +type connectionRetryFocusReason string + +const ( + focusReasonUndispatchedConnectionFailure connectionRetryFocusReason = "undispatched_connection_failure" + focusReasonPreAcceptTimeout connectionRetryFocusReason = "pre_accept_timeout" + focusReasonMainThreadStall connectionRetryFocusReason = "main_thread_stall" + focusReasonHeartbeatSilenceTimeout connectionRetryFocusReason = "heartbeat_silence_timeout" + focusReasonFinalResponseTimeout connectionRetryFocusReason = "final_response_timeout" +) + // Why: domain reload on large projects can keep the IPC endpoint down well past the // base retry window, and an undispatched dial failure is always safe to retry. While // a running Unity process is confirmed, the dial-retry window stretches to @@ -38,6 +49,13 @@ type unityServerNotRespondingError struct { cause error } +type connectionRetryFocusController struct { + connection unityipc.Connection + method string + attempted bool + restoreFocus restoreFocusFunc +} + func (err unityServerNotRespondingError) Error() string { if err.cause != nil { return fmt.Sprintf("Unity is running but the Unity CLI Loop server is not responding: %s", err.cause) @@ -56,6 +74,73 @@ func (err unityServerNotRespondingError) causeText() string { return err.cause.Error() } +func newConnectionRetryFocusController(connection unityipc.Connection, method string) *connectionRetryFocusController { + return &connectionRetryFocusController{ + connection: connection, + method: method, + } +} + +func (controller *connectionRetryFocusController) restore(ctx context.Context) { + if controller.restoreFocus == nil { + return + } + _ = controller.restoreFocus(ctx) +} + +func (controller *connectionRetryFocusController) keepUnityFocusedAfterReturn() { + // Why: terminal timeout recovery has no in-flight request left, so immediate + // restore hides the auto-front signal that tells the user Unity needs attention. + controller.restoreFocus = nil +} + +func (controller *connectionRetryFocusController) tryFocus( + ctx context.Context, + reason connectionRetryFocusReason, + cause error, +) { + if controller.attempted { + return + } + + runningProcess, err := findRunningUnityProcessForConnectionRetry(ctx, controller.connection.ProjectRoot) + if err != nil || runningProcess == nil { + controller.attempted = true + return + } + controller.tryFocusProcess(ctx, runningProcess.pid, reason, cause) +} + +func (controller *connectionRetryFocusController) tryFocusProcess( + ctx context.Context, + pid int, + reason connectionRetryFocusReason, + cause error, +) { + if controller.attempted { + return + } + controller.attempted = true + + correlationID := newCliVibeCorrelationID() + logConnectionRetryFocusAttempt(controller.connection, controller.method, pid, reason, cause, correlationID) + restorer, focusErr := focusUnityProcessForConnectionRetry(ctx, pid) + if focusErr == nil { + controller.restoreFocus = restorer + logConnectionRetryFocusSuccess(controller.connection, controller.method, pid, reason, cause, correlationID) + return + } + logConnectionRetryFocusFailure(controller.connection, controller.method, pid, reason, cause, focusErr, correlationID) +} + +func (controller *connectionRetryFocusController) handleMainThreadStall(ctx context.Context, stallSeconds float64) { + controller.tryFocus( + ctx, + focusReasonMainThreadStall, + fmt.Errorf("unity editor main thread busy for %.0fs", stallSeconds), + ) +} + func sendWithTransientConnectionRetry( ctx context.Context, connection unityipc.Connection, @@ -87,17 +172,13 @@ func sendWithTransientConnectionRetryAndResponseTimeout( var lastOutcome unityipc.UnitySendOutcome var lastErr error - var restoreFocus restoreFocusFunc + focusController := newConnectionRetryFocusController(connection, method) defer func() { - if restoreFocus == nil { - return - } restoreContext, cancel := context.WithTimeout(context.Background(), focusRestoreTimeout) defer cancel() - _ = restoreFocus(restoreContext) + focusController.restore(restoreContext) }() - focusAttempted := false retryTicker := time.NewTicker(serverConnectionRetryPoll) defer retryTicker.Stop() for { @@ -105,6 +186,9 @@ func sendWithTransientConnectionRetryAndResponseTimeout( if responseTimeout > 0 { client = client.WithResponseTimeout(responseTimeout) } + client = client.WithMainThreadStallHandler(func(stallSeconds float64) { + focusController.handleMainThreadStall(ctx, stallSeconds) + }) // Each attempt keeps the base accept bound: a dispatched request that never gets // acked must still fail at the base window. Only the dial-retry loop as a whole // uses the extended unity-alive window. @@ -145,6 +229,10 @@ func sendWithTransientConnectionRetryAndResponseTimeout( } return lastOutcome, lastErr } + if reason, ok := connectionRetryFocusReasonForError(err, outcome, responseTimeout); ok { + focusController.tryFocus(ctx, reason, err) + focusController.keepUnityFocusedAfterReturn() + } return outcome, err } @@ -176,18 +264,12 @@ func sendWithTransientConnectionRetryAndResponseTimeout( } return outcome, err } - if !focusAttempted { - correlationID := newCliVibeCorrelationID() - logConnectionRetryFocusAttempt(connection, method, runningProcess.pid, err, correlationID) - restorer, focusErr := focusUnityProcessForConnectionRetry(retryContext, runningProcess.pid) - if focusErr == nil { - restoreFocus = restorer - logConnectionRetryFocusSuccess(connection, method, runningProcess.pid, err, correlationID) - } else { - logConnectionRetryFocusFailure(connection, method, runningProcess.pid, err, focusErr, correlationID) - } - focusAttempted = true - } + focusController.tryFocusProcess( + retryContext, + runningProcess.pid, + focusReasonUndispatchedConnectionFailure, + err, + ) lastOutcome = outcome lastErr = err @@ -235,6 +317,39 @@ func isUnityServerBusyRPCError(err error) bool { return rpcDataType(decodedData) == "server_busy" } +func connectionRetryFocusReasonForError( + err error, + outcome unityipc.UnitySendOutcome, + responseTimeout time.Duration, +) (connectionRetryFocusReason, bool) { + if err == nil || isUnityServerBusyRPCError(err) || isRPCError(err) { + return "", false + } + + var unresponsiveErr *unityipc.EditorUnresponsiveError + if errors.As(err, &unresponsiveErr) { + return focusReasonMainThreadStall, true + } + + if outcome.RequestDispatched && !outcome.RequestAccepted && isFinalResponseTimeoutError(err) { + return focusReasonPreAcceptTimeout, true + } + + if !outcome.RequestAccepted { + return "", false + } + if responseTimeout > 0 { + return "", false + } + if !isFinalResponseTimeoutError(err) { + return "", false + } + if strings.Contains(err.Error(), "heartbeat") { + return focusReasonHeartbeatSilenceTimeout, true + } + return focusReasonFinalResponseTimeout, true +} + func shouldRetryUndispatchedConnection(err error, outcome unityipc.UnitySendOutcome) bool { if err == nil || outcome.RequestDispatched { return false @@ -248,17 +363,19 @@ func logConnectionRetryFocusAttempt( connection unityipc.Connection, method string, pid int, + reason connectionRetryFocusReason, retryCause error, correlationID string, ) { _ = writeCliVibeLog(connection.ProjectRoot, cliVibeLogEntry{ Level: "INFO", Operation: "cli_connection_retry_focus_attempt", - Message: "Attempting to focus Unity before retrying an undispatched request.", + Message: "Attempting to focus Unity while recovering a slow or unreachable request.", Context: map[string]any{ "command": method, "pid": pid, "endpoint": connection.Endpoint.Address, + "reason": string(reason), "cause": errorMessage(retryCause), }, CorrelationID: correlationID, @@ -269,17 +386,19 @@ func logConnectionRetryFocusSuccess( connection unityipc.Connection, method string, pid int, + reason connectionRetryFocusReason, retryCause error, correlationID string, ) { _ = writeCliVibeLog(connection.ProjectRoot, cliVibeLogEntry{ Level: "INFO", Operation: "cli_connection_retry_focus_success", - Message: "Focused Unity before retrying an undispatched request.", + Message: "Focused Unity while recovering a slow or unreachable request.", Context: map[string]any{ "command": method, "pid": pid, "endpoint": connection.Endpoint.Address, + "reason": string(reason), "cause": errorMessage(retryCause), }, CorrelationID: correlationID, @@ -290,6 +409,7 @@ func logConnectionRetryFocusFailure( connection unityipc.Connection, method string, pid int, + reason connectionRetryFocusReason, retryCause error, focusErr error, correlationID string, @@ -302,6 +422,7 @@ func logConnectionRetryFocusFailure( "command": method, "pid": pid, "endpoint": connection.Endpoint.Address, + "reason": string(reason), "cause": errorMessage(retryCause), "focusError": errorMessage(focusErr), }, diff --git a/cli/internal/cli/connection_retry_test.go b/cli/internal/cli/connection_retry_test.go index 21abf8f21f..94ea944fdc 100644 --- a/cli/internal/cli/connection_retry_test.go +++ b/cli/internal/cli/connection_retry_test.go @@ -115,6 +115,7 @@ func TestSendWithTransientConnectionRetryWritesFocusSuccessVibeLog(t *testing.T) `"operation":"cli_connection_retry_focus_success"`, `"command":"get-logs"`, `"pid":456`, + `"reason":"undispatched_connection_failure"`, } { if !strings.Contains(logContent, expected) { t.Fatalf("CLI Vibe log missing %q:\n%s", expected, logContent) @@ -167,6 +168,7 @@ func TestSendWithTransientConnectionRetryWritesFocusFailureVibeLog(t *testing.T) `"operation":"cli_connection_retry_focus_failed"`, `"command":"compile"`, `"pid":789`, + `"reason":"undispatched_connection_failure"`, `"focusError":"focus denied"`, } { if !strings.Contains(logContent, expected) { @@ -229,6 +231,96 @@ func readOnlyCliVibeLog(t *testing.T, projectRoot string) string { return string(content) } +// Verifies dispatched pre-accept timeouts are treated as focus-worthy slow Unity responses. +func TestConnectionRetryFocusReasonClassifiesPreAcceptTimeout(t *testing.T) { + reason, ok := connectionRetryFocusReasonForError( + timeoutOnlyError{}, + unityipc.UnitySendOutcome{RequestDispatched: true}, + 0) + + if !ok { + t.Fatal("expected pre-accept timeout to request focus") + } + if reason != focusReasonPreAcceptTimeout { + t.Fatalf("focus reason mismatch: %s", reason) + } +} + +// Verifies heartbeat silence after acceptance is treated as an abnormal timeout. +func TestConnectionRetryFocusReasonClassifiesHeartbeatSilenceTimeout(t *testing.T) { + err := fmt.Errorf("no response or heartbeat from Unity for 6s: %w", timeoutOnlyError{}) + reason, ok := connectionRetryFocusReasonForError( + err, + unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, + 0) + + if !ok { + t.Fatal("expected heartbeat silence timeout to request focus") + } + if reason != focusReasonHeartbeatSilenceTimeout { + t.Fatalf("focus reason mismatch: %s", reason) + } +} + +// Verifies abnormal final response timeouts after acceptance request focus. +func TestConnectionRetryFocusReasonClassifiesFinalResponseTimeout(t *testing.T) { + reason, ok := connectionRetryFocusReasonForError( + timeoutOnlyError{}, + unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, + 0) + + if !ok { + t.Fatal("expected final response timeout to request focus") + } + if reason != focusReasonFinalResponseTimeout { + t.Fatalf("focus reason mismatch: %s", reason) + } +} + +// Verifies explicit response timeouts such as compile status polling do not request focus. +func TestConnectionRetryFocusReasonSkipsIntentionalResponseTimeout(t *testing.T) { + _, ok := connectionRetryFocusReasonForError( + timeoutOnlyError{}, + unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, + compileResponseTimeout) + + if ok { + t.Fatal("intentional response timeout should not request focus") + } +} + +// Verifies server_busy responses do not request focus because Unity is already answering. +func TestConnectionRetryFocusReasonSkipsServerBusy(t *testing.T) { + err := &unityipc.RPCError{ + Code: -32603, + Message: "Unity is busy running 'compile'.", + Data: []byte(`{"type":"server_busy"}`), + } + _, ok := connectionRetryFocusReasonForError( + err, + unityipc.UnitySendOutcome{RequestDispatched: true}, + 0) + + if ok { + t.Fatal("server_busy should not request focus") + } +} + +// Verifies editor-unresponsive heartbeat failures are treated as main-thread stalls. +func TestConnectionRetryFocusReasonClassifiesEditorUnresponsive(t *testing.T) { + reason, ok := connectionRetryFocusReasonForError( + &unityipc.EditorUnresponsiveError{StallSeconds: 300}, + unityipc.UnitySendOutcome{RequestDispatched: true, RequestAccepted: true}, + 0) + + if !ok { + t.Fatal("expected editor-unresponsive error to request focus") + } + if reason != focusReasonMainThreadStall { + t.Fatalf("focus reason mismatch: %s", reason) + } +} + // Verifies accepted RPCs can outlive the pre-dispatch connection retry timeout. func TestSendWithTransientConnectionRetryDoesNotCancelAcceptedRequestAtRetryTimeout(t *testing.T) { if runtime.GOOS == "windows" { @@ -386,6 +478,86 @@ func TestSendWithTransientConnectionRetryKeepsRetryTimeoutBeforeAcceptedAck(t *t } } +// Verifies terminal timeout focus remains visible after the command returns. +func TestSendWithTransientConnectionRetryKeepsUnityFocusedAfterPreAcceptTimeout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("TCP endpoint injection is only used by this non-Windows client test") + } + + originalFinder := findRunningUnityProcessForConnectionRetry + originalFocus := focusUnityProcessForConnectionRetry + originalTimeout := serverConnectionRetryTimeout + focusCallCount := 0 + restoreCallCount := 0 + findRunningUnityProcessForConnectionRetry = func(context.Context, string) (*unityProcess, error) { + return &unityProcess{pid: 123}, nil + } + focusUnityProcessForConnectionRetry = func(context.Context, int) (restoreFocusFunc, error) { + focusCallCount++ + return func(context.Context) error { + restoreCallCount++ + return nil + }, nil + } + serverConnectionRetryTimeout = 100 * time.Millisecond + t.Cleanup(func() { + findRunningUnityProcessForConnectionRetry = originalFinder + focusUnityProcessForConnectionRetry = originalFocus + serverConnectionRetryTimeout = originalTimeout + }) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer func() { + _ = listener.Close() + }() + + releaseServer := make(chan struct{}) + go func() { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + defer func() { + _ = conn.Close() + }() + if _, readErr := unityipc.Read(bufio.NewReader(conn)); readErr != nil { + return + } + <-releaseServer + }() + t.Cleanup(func() { + close(releaseServer) + }) + + connection := unityipc.Connection{ + Endpoint: unityipc.Endpoint{ + Network: "tcp", + Address: listener.Addr().String(), + }, + ProjectRoot: t.TempDir(), + } + + _, err = sendWithTransientConnectionRetry( + context.Background(), + connection, + "get-logs", + map[string]any{}, + nil) + + if err == nil { + t.Fatal("expected pre-accept timeout") + } + if focusCallCount != 1 { + t.Fatalf("expected one focus attempt, got %d", focusCallCount) + } + if restoreCallCount != 0 { + t.Fatalf("terminal timeout focus should not be restored immediately, got %d restores", restoreCallCount) + } +} + // Verifies a server_busy response is retried, because the request was never executed // and Unity frees the execution slot when the running tool completes. func TestSendWithTransientConnectionRetryRetriesBusyResponses(t *testing.T) { diff --git a/cli/internal/cli/focus.go b/cli/internal/cli/focus.go index b03568ccff..96a02fafbb 100644 --- a/cli/internal/cli/focus.go +++ b/cli/internal/cli/focus.go @@ -356,7 +356,7 @@ func buildFocusUnityProcessWindowsScript(pid int) string { scriptLines := []string{ "$ErrorActionPreference = 'Stop'", } - scriptLines = append(scriptLines, buildWindowsFocusInteropTypeDefinition(false)...) + scriptLines = append(scriptLines, buildWindowsFocusInteropTypeDefinition(false, false)...) scriptLines = append(scriptLines, buildWindowsFocusTargetScriptLines(pid)...) return strings.Join(scriptLines, "\n") } @@ -365,7 +365,7 @@ func buildFocusUnityProcessWindowsWithRestoreScript(pid int) string { scriptLines := []string{ "$ErrorActionPreference = 'Stop'", } - scriptLines = append(scriptLines, buildWindowsFocusInteropTypeDefinition(true)...) + scriptLines = append(scriptLines, buildWindowsFocusInteropTypeDefinition(true, false)...) scriptLines = append(scriptLines, "$previous = [Win32Interop]::GetForegroundWindow()", ) @@ -374,7 +374,7 @@ func buildFocusUnityProcessWindowsWithRestoreScript(pid int) string { return strings.Join(scriptLines, "\n") } -func buildWindowsFocusInteropTypeDefinition(includeGetForegroundWindow bool) []string { +func buildWindowsFocusInteropTypeDefinition(includeGetForegroundWindow bool, includeThreadFocus bool) []string { addTypeLines := []string{ "Add-Type -TypeDefinition @\"", "using System;", @@ -389,6 +389,17 @@ func buildWindowsFocusInteropTypeDefinition(includeGetForegroundWindow bool) []s addTypeLines = append(addTypeLines, " [DllImport(\"user32.dll\")] public static extern bool SetForegroundWindow(IntPtr hWnd);", " [DllImport(\"user32.dll\")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);", + ) + if includeThreadFocus { + addTypeLines = append(addTypeLines, + " [DllImport(\"user32.dll\")] public static extern bool BringWindowToTop(IntPtr hWnd);", + " [DllImport(\"user32.dll\")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId);", + " [DllImport(\"kernel32.dll\")] public static extern uint GetCurrentThreadId();", + " [DllImport(\"user32.dll\")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);", + " [DllImport(\"user32.dll\")] public static extern bool IsIconic(IntPtr hWnd);", + ) + } + addTypeLines = append(addTypeLines, "}", "\"@", ) @@ -428,11 +439,35 @@ func buildRestoreWindowsForegroundWindowScript(handle int64) string { scriptLines := []string{ "$ErrorActionPreference = 'Stop'", } - scriptLines = append(scriptLines, buildWindowsFocusInteropTypeDefinition(false)...) + scriptLines = append(scriptLines, buildWindowsFocusInteropTypeDefinition(true, true)...) scriptLines = append(scriptLines, fmt.Sprintf("$handle = [IntPtr]::new(%d)", handle), "if ($handle -eq [IntPtr]::Zero) { throw 'Saved foreground window handle is invalid' }", - "$restored = [Win32Interop]::SetForegroundWindow($handle)", + "$targetThreadId = [Win32Interop]::GetWindowThreadProcessId($handle, [IntPtr]::Zero)", + "if ($targetThreadId -eq 0) { throw 'Saved foreground window thread is invalid' }", + "$foreground = [Win32Interop]::GetForegroundWindow()", + "$foregroundThreadId = [Win32Interop]::GetWindowThreadProcessId($foreground, [IntPtr]::Zero)", + "$currentThreadId = [Win32Interop]::GetCurrentThreadId()", + "$attachedCurrent = $false", + "$attachedForeground = $false", + "try {", + " if ($targetThreadId -ne $currentThreadId) {", + " $attachedCurrent = [Win32Interop]::AttachThreadInput($currentThreadId, $targetThreadId, $true)", + " }", + " if ($foregroundThreadId -ne 0 -and $foregroundThreadId -ne $targetThreadId) {", + " $attachedForeground = [Win32Interop]::AttachThreadInput($foregroundThreadId, $targetThreadId, $true)", + " }", + " $isMinimized = [Win32Interop]::IsIconic($handle)", + " if ($isMinimized) {", + " $shown = [Win32Interop]::ShowWindowAsync($handle, 9)", + " if (-not $shown) { throw 'Failed to show previous foreground window' }", + " }", + " [void][Win32Interop]::BringWindowToTop($handle)", + " $restored = [Win32Interop]::SetForegroundWindow($handle)", + "} finally {", + " if ($attachedForeground) { [void][Win32Interop]::AttachThreadInput($foregroundThreadId, $targetThreadId, $false) }", + " if ($attachedCurrent) { [void][Win32Interop]::AttachThreadInput($currentThreadId, $targetThreadId, $false) }", + "}", "if (-not $restored) { throw 'Failed to restore previous foreground window' }", ) return strings.Join(scriptLines, "\n") diff --git a/cli/internal/cli/focus_test.go b/cli/internal/cli/focus_test.go index 6c38f8c6db..35dafd4ffb 100644 --- a/cli/internal/cli/focus_test.go +++ b/cli/internal/cli/focus_test.go @@ -103,6 +103,14 @@ func TestBuildRestoreWindowsForegroundWindowScriptThrowsOnRestoreFailure(t *test for _, expected := range []string{ "$handle = [IntPtr]::new(123)", "if ($handle -eq [IntPtr]::Zero) { throw 'Saved foreground window handle is invalid' }", + "GetWindowThreadProcessId", + "GetCurrentThreadId", + "AttachThreadInput", + "BringWindowToTop", + "try {", + "} finally {", + "AttachThreadInput($foregroundThreadId, $targetThreadId, $false)", + "AttachThreadInput($currentThreadId, $targetThreadId, $false)", "$restored = [Win32Interop]::SetForegroundWindow($handle)", "if (-not $restored) { throw 'Failed to restore previous foreground window' }", } { @@ -112,6 +120,25 @@ func TestBuildRestoreWindowsForegroundWindowScriptThrowsOnRestoreFailure(t *test } } +// Verifies Windows restore avoids resizing a saved maximized foreground window. +func TestBuildRestoreWindowsForegroundWindowScriptRestoresOnlyMinimizedWindow(t *testing.T) { + script := buildRestoreWindowsForegroundWindowScript(123) + + for _, expected := range []string{ + "IsIconic", + "$isMinimized = [Win32Interop]::IsIconic($handle)", + "if ($isMinimized) {", + " $shown = [Win32Interop]::ShowWindowAsync($handle, 9)", + } { + if !strings.Contains(script, expected) { + t.Fatalf("script missing %q: %s", expected, script) + } + } + if strings.Contains(script, "\n $shown = [Win32Interop]::ShowWindowAsync($handle, 9)") { + t.Fatalf("restore script should not unconditionally restore the previous window: %s", script) + } +} + // Verifies Windows foreground handle parsing ignores invalid saved state output. func TestParseWindowsForegroundHandle(t *testing.T) { if parseWindowsForegroundHandle("123\r\n") != 123 { diff --git a/cli/internal/unityipc/client.go b/cli/internal/unityipc/client.go index 81b45a1c12..6a376696a3 100644 --- a/cli/internal/unityipc/client.go +++ b/cli/internal/unityipc/client.go @@ -44,6 +44,7 @@ type Client struct { // Test seams: zero means "use the derived/default values". heartbeatSilenceOverride time.Duration mainThreadStallLimit time.Duration + mainThreadStallHandler func(float64) } type ProgressFunc = func(message string) @@ -136,6 +137,11 @@ func (client *Client) WithResponseTimeout(timeout time.Duration) *Client { return client } +func (client *Client) WithMainThreadStallHandler(handler func(float64)) *Client { + client.mainThreadStallHandler = handler + return client +} + func (client *Client) Send(ctx context.Context, method string, params map[string]any) (json.RawMessage, error) { return client.SendWithProgress(ctx, method, params, nil) } @@ -259,14 +265,19 @@ func (client *Client) SendWithProgressOutcomeAcceptContext( } stallSeconds := response.ULoop.MainThreadStallSeconds + if stallSeconds >= mainThreadStallProgressThresholdSeconds { + if client.mainThreadStallHandler != nil { + client.mainThreadStallHandler(stallSeconds) + } + if progress != nil { + progress(fmt.Sprintf("unity editor main thread busy for %.0fs...", stallSeconds)) + } + } if stallSeconds >= client.getMainThreadStallLimit().Seconds() { timing.Total = time.Since(startedAt) outcome.Timing = timing return outcome, &EditorUnresponsiveError{StallSeconds: stallSeconds} } - if progress != nil && stallSeconds >= mainThreadStallProgressThresholdSeconds { - progress(fmt.Sprintf("unity editor main thread busy for %.0fs...", stallSeconds)) - } if heartbeatSilence > 0 { if err := applyPostAcceptDeadline(conn, heartbeatSilence, absoluteDeadline); err != nil { timing.Total = time.Since(startedAt) diff --git a/cli/internal/unityipc/client_heartbeat_test.go b/cli/internal/unityipc/client_heartbeat_test.go index b6263329d7..1ebd4c19d8 100644 --- a/cli/internal/unityipc/client_heartbeat_test.go +++ b/cli/internal/unityipc/client_heartbeat_test.go @@ -141,6 +141,33 @@ func TestSendKeepsWaitingWhileHeartbeatsArrive(t *testing.T) { } } +// Verifies heartbeat main-thread stall reports reach the typed handler before the final response. +func TestSendReportsMainThreadStallToHandler(t *testing.T) { + stalledHeartbeat := `{"jsonrpc":"2.0","id":1,"result":{"alive":true},"uloop":{"phase":"heartbeat","mainThreadStallSeconds":31}}` + connection := startHeartbeatTestServer(t, func(conn net.Conn) { + writeFrame(t, conn, heartbeatAck) + writeFrame(t, conn, stalledHeartbeat) + writeFrame(t, conn, `{"jsonrpc":"2.0","id":1,"result":{"ok":true}}`) + }) + + stallReports := []float64{} + client := NewClient(connection, "9.9.9").WithMainThreadStallHandler(func(stallSeconds float64) { + stallReports = append(stallReports, stallSeconds) + }) + client.heartbeatSilenceOverride = 5 * time.Second + + outcome, err := client.SendWithProgressOutcome(context.Background(), "run-tests", map[string]any{}, nil) + if err != nil { + t.Fatalf("expected success after stall heartbeat, got %v", err) + } + if len(outcome.Result) == 0 { + t.Fatal("expected final result") + } + if len(stallReports) != 1 || stallReports[0] != 31 { + t.Fatalf("stall reports mismatch: %#v", stallReports) + } +} + // Verifies that a negotiated connection fails with a heartbeat-silence diagnosis when // frames stop arriving, instead of waiting for the 30-minute absolute deadline. func TestSendFailsWithDiagnosisWhenHeartbeatsStop(t *testing.T) { From 16e1a4ea0bb31c337a0515adddecc75d3bde8243 Mon Sep 17 00:00:00 2001 From: masamichi Date: Tue, 16 Jun 2026 11:58:53 +0900 Subject: [PATCH 2/2] Retry focus after process discovery misses A transient Unity process lookup failure should not consume the one focus attempt for a command. Leave the controller retryable until a real process focus attempt runs, and cover the later terminal-timeout recovery path with a focused test. --- cli/internal/cli/connection_retry.go | 1 - cli/internal/cli/connection_retry_test.go | 36 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/cli/internal/cli/connection_retry.go b/cli/internal/cli/connection_retry.go index 0ea0207a13..b6f35495a3 100644 --- a/cli/internal/cli/connection_retry.go +++ b/cli/internal/cli/connection_retry.go @@ -105,7 +105,6 @@ func (controller *connectionRetryFocusController) tryFocus( runningProcess, err := findRunningUnityProcessForConnectionRetry(ctx, controller.connection.ProjectRoot) if err != nil || runningProcess == nil { - controller.attempted = true return } controller.tryFocusProcess(ctx, runningProcess.pid, reason, cause) diff --git a/cli/internal/cli/connection_retry_test.go b/cli/internal/cli/connection_retry_test.go index 94ea944fdc..9ddeb78867 100644 --- a/cli/internal/cli/connection_retry_test.go +++ b/cli/internal/cli/connection_retry_test.go @@ -321,6 +321,42 @@ func TestConnectionRetryFocusReasonClassifiesEditorUnresponsive(t *testing.T) { } } +// Verifies a transient process discovery miss does not suppress a later focus attempt. +func TestConnectionRetryFocusControllerRetriesAfterProcessDiscoveryMiss(t *testing.T) { + originalFinder := findRunningUnityProcessForConnectionRetry + originalFocus := focusUnityProcessForConnectionRetry + findCallCount := 0 + focusCallCount := 0 + findRunningUnityProcessForConnectionRetry = func(context.Context, string) (*unityProcess, error) { + findCallCount++ + if findCallCount == 1 { + return nil, nil + } + return &unityProcess{pid: 123}, nil + } + focusUnityProcessForConnectionRetry = func(context.Context, int) (restoreFocusFunc, error) { + focusCallCount++ + return nil, nil + } + t.Cleanup(func() { + findRunningUnityProcessForConnectionRetry = originalFinder + focusUnityProcessForConnectionRetry = originalFocus + }) + + connection := unityipc.Connection{ProjectRoot: t.TempDir()} + controller := newConnectionRetryFocusController(connection, "get-logs") + + controller.tryFocus(context.Background(), focusReasonMainThreadStall, errors.New("first probe missed")) + controller.tryFocus(context.Background(), focusReasonFinalResponseTimeout, errors.New("terminal timeout")) + + if findCallCount != 2 { + t.Fatalf("expected process discovery to retry, got %d calls", findCallCount) + } + if focusCallCount != 1 { + t.Fatalf("expected later focus attempt to run once, got %d calls", focusCallCount) + } +} + // Verifies accepted RPCs can outlive the pre-dispatch connection retry timeout. func TestSendWithTransientConnectionRetryDoesNotCancelAcceptedRequestAtRetryTimeout(t *testing.T) { if runtime.GOOS == "windows" {