From 961b6cc9d61fdfe9d0a6181f2d3e8836985093f6 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 16 Jun 2026 23:20:54 +0900 Subject: [PATCH 1/2] Surface Unity main-thread stall diagnostics Classify heartbeat-based editor unresponsive errors separately so CLI output points users at possible modal dialogs or long editor operations. Update stall progress text and cover both envelope and heartbeat progress behavior with tests. --- cli/internal/cli/error_editor_unresponsive.go | 35 +++++++++++++++++++ cli/internal/cli/error_envelope.go | 17 ++++----- cli/internal/cli/error_envelope_test.go | 26 ++++++++++++++ cli/internal/unityipc/client.go | 4 ++- .../unityipc/client_heartbeat_test.go | 34 ++++++++++++++++++ 5 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 cli/internal/cli/error_editor_unresponsive.go diff --git a/cli/internal/cli/error_editor_unresponsive.go b/cli/internal/cli/error_editor_unresponsive.go new file mode 100644 index 000000000..64d230cc6 --- /dev/null +++ b/cli/internal/cli/error_editor_unresponsive.go @@ -0,0 +1,35 @@ +package cli + +import "github.com/hatayama/unity-cli-loop/cli/internal/unityipc" + +func connectionAttemptCause(err *unityipc.ConnectionAttemptError) string { + if err == nil { + return "" + } + cause := err.Unwrap() + if cause == nil { + return "" + } + return cause.Error() +} + +func unityEditorUnresponsiveError(err *unityipc.EditorUnresponsiveError, context errorContext) cliError { + return cliError{ + ErrorCode: errorCodeUnityEditorUnresponsive, + Phase: errorPhaseResponseWaiting, + Message: err.Error(), + Retryable: true, + SafeToRetry: isSafeRetryCommand(context.command), + ProjectRoot: context.projectRoot, + Command: context.command, + NextActions: []string{ + "Check Unity for a modal dialog or long editor operation that is blocking the Editor main thread.", + "Run `uloop focus-window` if Unity is hidden behind another window.", + "Close the modal dialog or wait for the Editor operation to finish, then retry the command.", + }, + Details: map[string]any{ + "stallSeconds": err.StallSeconds, + "cause": err.Error(), + }, + } +} diff --git a/cli/internal/cli/error_envelope.go b/cli/internal/cli/error_envelope.go index 9b339569c..1f11c698a 100644 --- a/cli/internal/cli/error_envelope.go +++ b/cli/internal/cli/error_envelope.go @@ -20,6 +20,7 @@ const ( errorCodeUnityDisconnectedAfterDispatch = "UNITY_DISCONNECTED_AFTER_DISPATCH" errorCodeUnityDisconnectedAfterAccept = "UNITY_DISCONNECTED_AFTER_ACCEPT" errorCodeUnityResponseTimeoutAfterAccept = "UNITY_RESPONSE_TIMEOUT_AFTER_ACCEPT" + errorCodeUnityEditorUnresponsive = "UNITY_EDITOR_UNRESPONSIVE" errorCodeUnityRPCError = "UNITY_RPC_ERROR" errorCodeUnityServerBusy = "UNITY_SERVER_BUSY" errorCodeCLIUpdateRequired = "CLI_UPDATE_REQUIRED" @@ -180,6 +181,11 @@ func classifyError(err error, context errorContext) cliError { } } + var editorUnresponsiveErr *unityipc.EditorUnresponsiveError + if errors.As(err, &editorUnresponsiveErr) { + return unityEditorUnresponsiveError(editorUnresponsiveErr, context) + } + var connectionErr *unityipc.ConnectionAttemptError if errors.As(err, &connectionErr) { return cliError{ @@ -307,17 +313,6 @@ func classifyError(err error, context errorContext) cliError { return internalCLIError(message, context) } -func connectionAttemptCause(err *unityipc.ConnectionAttemptError) string { - if err == nil { - return "" - } - cause := err.Unwrap() - if cause == nil { - return "" - } - return cause.Error() -} - func rpcDataType(data map[string]any) string { if data == nil { return "" diff --git a/cli/internal/cli/error_envelope_test.go b/cli/internal/cli/error_envelope_test.go index 25ed98185..42b1d431a 100644 --- a/cli/internal/cli/error_envelope_test.go +++ b/cli/internal/cli/error_envelope_test.go @@ -140,6 +140,32 @@ func TestClassifyUnityServerNotRespondingError(t *testing.T) { } } +func TestClassifyEditorUnresponsiveError(t *testing.T) { + // Verifies main-thread stall diagnostics guide users toward modal dialogs instead of generic transport failures. + err := &unityipc.EditorUnresponsiveError{StallSeconds: 321} + + cliErr := classifyError(err, errorContext{projectRoot: "/tmp/MyProject", command: "get-logs"}) + if cliErr.ErrorCode != errorCodeUnityEditorUnresponsive { + t.Fatalf("error code mismatch: %#v", cliErr) + } + if cliErr.Phase != errorPhaseResponseWaiting { + t.Fatalf("phase mismatch: %#v", cliErr) + } + if !cliErr.Retryable || !cliErr.SafeToRetry { + t.Fatalf("retry flags mismatch: %#v", cliErr) + } + if cliErr.Details["stallSeconds"] != float64(321) { + t.Fatalf("stall seconds details mismatch: %#v", cliErr.Details) + } + joinedActions := strings.Join(cliErr.NextActions, "\n") + if !strings.Contains(joinedActions, "modal dialog") { + t.Fatalf("next actions should mention modal dialog: %#v", cliErr.NextActions) + } + if !strings.Contains(joinedActions, "uloop focus-window") { + t.Fatalf("next actions should mention focus-window: %#v", cliErr.NextActions) + } +} + func TestClassifyLaunchStartupTimeoutError(t *testing.T) { // Verifies launch startup timeouts do not look like generic reachability or package failures. cliErr := classifyError( diff --git a/cli/internal/unityipc/client.go b/cli/internal/unityipc/client.go index 6a376696a..ab517fdee 100644 --- a/cli/internal/unityipc/client.go +++ b/cli/internal/unityipc/client.go @@ -270,7 +270,9 @@ func (client *Client) SendWithProgressOutcomeAcceptContext( client.mainThreadStallHandler(stallSeconds) } if progress != nil { - progress(fmt.Sprintf("unity editor main thread busy for %.0fs...", stallSeconds)) + progress(fmt.Sprintf( + "unity editor main thread has not ticked for %.0fs; check Unity for a modal dialog or long editor operation...", + stallSeconds)) } } if stallSeconds >= client.getMainThreadStallLimit().Seconds() { diff --git a/cli/internal/unityipc/client_heartbeat_test.go b/cli/internal/unityipc/client_heartbeat_test.go index 1ebd4c19d..4b827b966 100644 --- a/cli/internal/unityipc/client_heartbeat_test.go +++ b/cli/internal/unityipc/client_heartbeat_test.go @@ -168,6 +168,40 @@ func TestSendReportsMainThreadStallToHandler(t *testing.T) { } } +// Verifies heartbeat stall progress points users at modal dialogs or long editor work. +func TestSendReportsMainThreadStallProgressWithModalHint(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}}`) + }) + + progressMessages := []string{} + client := NewClient(connection, "9.9.9") + client.heartbeatSilenceOverride = 5 * time.Second + + _, err := client.SendWithProgressOutcome( + context.Background(), + "run-tests", + map[string]any{}, + func(message string) { + progressMessages = append(progressMessages, message) + }, + ) + if err != nil { + t.Fatalf("expected success after stall heartbeat, got %v", err) + } + + joinedMessages := strings.Join(progressMessages, "\n") + if !strings.Contains(joinedMessages, "modal dialog") { + t.Fatalf("progress should mention modal dialog: %#v", progressMessages) + } + if !strings.Contains(joinedMessages, "long editor operation") { + t.Fatalf("progress should mention long editor operation: %#v", progressMessages) + } +} + // 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 454eace4d2afb8aab76f323c337f15507131eda0 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 16 Jun 2026 23:39:28 +0900 Subject: [PATCH 2/2] Refine Unity stall guidance Remove stale restart advice from the structured editor-unresponsive error and shorten the heartbeat stall progress text so it stays suitable for spinner output. --- cli/internal/cli/error_editor_unresponsive.go | 4 ++-- cli/internal/cli/error_envelope_test.go | 6 ++++++ cli/internal/unityipc/client.go | 2 +- cli/internal/unityipc/client_heartbeat_test.go | 8 ++++---- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/cli/internal/cli/error_editor_unresponsive.go b/cli/internal/cli/error_editor_unresponsive.go index 64d230cc6..a6f94a96c 100644 --- a/cli/internal/cli/error_editor_unresponsive.go +++ b/cli/internal/cli/error_editor_unresponsive.go @@ -17,7 +17,7 @@ func unityEditorUnresponsiveError(err *unityipc.EditorUnresponsiveError, context return cliError{ ErrorCode: errorCodeUnityEditorUnresponsive, Phase: errorPhaseResponseWaiting, - Message: err.Error(), + Message: "Unity accepted the request, but the Editor main thread stopped responding.", Retryable: true, SafeToRetry: isSafeRetryCommand(context.command), ProjectRoot: context.projectRoot, @@ -29,7 +29,7 @@ func unityEditorUnresponsiveError(err *unityipc.EditorUnresponsiveError, context }, Details: map[string]any{ "stallSeconds": err.StallSeconds, - "cause": err.Error(), + "cause": "Unity Editor main thread did not tick while the IPC heartbeat stayed alive.", }, } } diff --git a/cli/internal/cli/error_envelope_test.go b/cli/internal/cli/error_envelope_test.go index 42b1d431a..630ec3452 100644 --- a/cli/internal/cli/error_envelope_test.go +++ b/cli/internal/cli/error_envelope_test.go @@ -151,12 +151,18 @@ func TestClassifyEditorUnresponsiveError(t *testing.T) { if cliErr.Phase != errorPhaseResponseWaiting { t.Fatalf("phase mismatch: %#v", cliErr) } + if strings.Contains(cliErr.Message, "launch -r") || strings.Contains(strings.ToLower(cliErr.Message), "restart") { + t.Fatalf("message should not include stale restart advice: %s", cliErr.Message) + } if !cliErr.Retryable || !cliErr.SafeToRetry { t.Fatalf("retry flags mismatch: %#v", cliErr) } if cliErr.Details["stallSeconds"] != float64(321) { t.Fatalf("stall seconds details mismatch: %#v", cliErr.Details) } + if strings.Contains(cliErr.Details["cause"].(string), "launch -r") { + t.Fatalf("cause should not include stale restart advice: %#v", cliErr.Details) + } joinedActions := strings.Join(cliErr.NextActions, "\n") if !strings.Contains(joinedActions, "modal dialog") { t.Fatalf("next actions should mention modal dialog: %#v", cliErr.NextActions) diff --git a/cli/internal/unityipc/client.go b/cli/internal/unityipc/client.go index ab517fdee..064928ba0 100644 --- a/cli/internal/unityipc/client.go +++ b/cli/internal/unityipc/client.go @@ -271,7 +271,7 @@ func (client *Client) SendWithProgressOutcomeAcceptContext( } if progress != nil { progress(fmt.Sprintf( - "unity editor main thread has not ticked for %.0fs; check Unity for a modal dialog or long editor operation...", + "Unity main thread stuck %.0fs; check modal/long operation...", stallSeconds)) } } diff --git a/cli/internal/unityipc/client_heartbeat_test.go b/cli/internal/unityipc/client_heartbeat_test.go index 4b827b966..0f935190e 100644 --- a/cli/internal/unityipc/client_heartbeat_test.go +++ b/cli/internal/unityipc/client_heartbeat_test.go @@ -194,11 +194,11 @@ func TestSendReportsMainThreadStallProgressWithModalHint(t *testing.T) { } joinedMessages := strings.Join(progressMessages, "\n") - if !strings.Contains(joinedMessages, "modal dialog") { - t.Fatalf("progress should mention modal dialog: %#v", progressMessages) + if !strings.Contains(joinedMessages, "modal") { + t.Fatalf("progress should mention modal: %#v", progressMessages) } - if !strings.Contains(joinedMessages, "long editor operation") { - t.Fatalf("progress should mention long editor operation: %#v", progressMessages) + if !strings.Contains(joinedMessages, "long operation") { + t.Fatalf("progress should mention long operation: %#v", progressMessages) } }