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
35 changes: 35 additions & 0 deletions cli/internal/cli/error_editor_unresponsive.go
Original file line number Diff line number Diff line change
@@ -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: "Unity accepted the request, but the Editor main thread stopped responding.",
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": "Unity Editor main thread did not tick while the IPC heartbeat stayed alive.",
},
}
}
17 changes: 6 additions & 11 deletions cli/internal/cli/error_envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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 ""
Expand Down
32 changes: 32 additions & 0 deletions cli/internal/cli/error_envelope_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,38 @@ 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 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)
}
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(
Expand Down
4 changes: 3 additions & 1 deletion cli/internal/unityipc/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 main thread stuck %.0fs; check modal/long operation...",
stallSeconds))
}
}
if stallSeconds >= client.getMainThreadStallLimit().Seconds() {
Expand Down
34 changes: 34 additions & 0 deletions cli/internal/unityipc/client_heartbeat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
t.Fatalf("progress should mention modal: %#v", progressMessages)
}
if !strings.Contains(joinedMessages, "long operation") {
t.Fatalf("progress should mention long 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) {
Expand Down
Loading