Merge pull request #9900 from SivanCola/fix/desktop-rejection-containment

fix(desktop): contain rejected backend calls instead of the crash overlay / 后端调用失败改为提示而非崩溃遮罩
This commit is contained in:
SivanCola authored and GitHub committed 2026-09-07 23:34:00 +08:00
commit 17a81d7b79
12 files changed
+319 -21

No files matched your search

+2
View File
@@ -30,6 +30,7 @@ import {
TerminalSquare,
} from "lucide-react";
import { useToast } from "./lib/toast";
import { onRecoverableError } from "./lib/globalCrashHandlers";
import { useGoalActionHandler } from "./lib/goalAction";
import { useWailsResizeFix } from "./lib/useWailsResizeFix";
import { asArray } from "./lib/array";
@@ -1810,6 +1811,7 @@ export default function App() {
}
drainExtensionNotifications();
}, [state.extensionNotifications, showToast, drainExtensionNotifications]);
useEffect(() => onRecoverableError(({ message }) => showToast(message, "warn", { durationMs: 6000 })), [showToast]);
const extensionStatusList = useMemo(() => Object.values(state.extensionStatuses ?? {}), [state.extensionStatuses]);
const patchActiveComposerProfile = useCallback(
(patch: Partial<Omit<ComposerProfile, "pending">>, pendingFields: ComposerProfileField[]) => {
@@ -0,0 +1,87 @@
// Run: tsx src/__tests__/crash-rejection-containment.test.ts
//
// A rejected Wails call arrives as a bare string (or a stackless object). That
// is an ordinary backend error and must never paint the full-screen crash
// overlay; only rejections carrying a real stack keep the crash surface.
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost/" });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.CustomEvent = dom.window.CustomEvent;
globalThis.Event = dom.window.Event;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
const { installGlobalCrashHandlers, isRecoverableRejectionReason, onRecoverableError } = await import("../lib/globalCrashHandlers");
const { setFrontendDiagnosticSink } = await import("../lib/frontendDiagnosticBridge");
let passed = 0;
let failed = 0;
function ok(cond: boolean, label: string) {
if (cond) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
const diagnostics: string[] = [];
setFrontendDiagnosticSink((_source, type) => { diagnostics.push(type); });
const toasts: string[] = [];
onRecoverableError(({ message }) => { toasts.push(message); });
const originalConsoleError = console.error;
console.error = () => {};
// Mirrors main.tsx: bridge-level Wails filters are installed before the crash handlers.
window.addEventListener("unhandledrejection", (e) => { if (e.reason === "bridge-filtered") e.preventDefault(); });
installGlobalCrashHandlers();
function rejectWith(reason: unknown): Event {
const event = new dom.window.Event("unhandledrejection", { cancelable: true });
Object.defineProperty(event, "reason", { value: reason });
window.dispatchEvent(event);
return event;
}
const overlay = () => document.getElementById("crash-overlay");
console.log("\ncrash rejection containment");
const stacked = new TypeError("renderer fault");
stacked.stack = "TypeError: renderer fault\n at render (src/App.tsx:12:3)";
ok(isRecoverableRejectionReason("selected branch is outside the recovery lineage"), "a bare Go error string is recoverable");
ok(isRecoverableRejectionReason({ message: "stackless error-like object" }), "a stackless error-like object is recoverable");
ok(isRecoverableRejectionReason(undefined), "an undefined reason is recoverable");
ok(!isRecoverableRejectionReason(stacked), "an Error with a stack is a genuine crash");
const backend = rejectWith("selected branch is outside the recovery lineage");
ok(backend.defaultPrevented, "a backend rejection is marked handled");
ok(overlay() === null, "a backend rejection does not paint the crash overlay");
ok(
toasts.length === 1 && toasts[0] === "selected branch is outside the recovery lineage",
"a backend rejection dispatches the recoverable-error event with its message",
);
ok(diagnostics.includes("unhandled-backend-rejection"), "a backend rejection records a frontend diagnostic");
rejectWith('remote tab "remote-1" status was superseded by newer runtime state');
ok(overlay() === null && toasts.length === 1, "existing crash suppressions still short-circuit before containment");
rejectWith("bridge-filtered");
ok(overlay() === null && toasts.length === 1, "a rejection already handled by a bridge filter is left alone");
const crash = rejectWith(stacked);
ok(!crash.defaultPrevented, "a stacked Error rejection stays unhandled for the crash surface");
ok((overlay()?.textContent ?? "").includes("renderer fault"), "a stacked Error rejection still paints the crash overlay");
ok(toasts.length === 1, "a stacked Error rejection is not downgraded to a toast");
overlay()?.remove();
window.dispatchEvent(new dom.window.ErrorEvent("error", { message: "renderer fault", error: stacked, cancelable: true }));
ok((overlay()?.textContent ?? "").includes("renderer fault"), "window.error still paints the crash overlay");
console.error = originalConsoleError;
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
@@ -1,6 +1,7 @@
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import type { AppBindings } from "../lib/bridge";
import type { RecoveryLineageView } from "../lib/types";
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { pretendToBeVisual: true, url: "http://localhost/" });
@@ -19,6 +20,8 @@ globalThis.localStorage = dom.window.localStorage;
const { RecoveryLineageDialog } = await import("../components/RecoveryLineageDialog");
const { LocaleProvider } = await import("../lib/i18n");
const { ToastProvider } = await import("../lib/toast");
const { setFrontendDiagnosticSink } = await import("../lib/frontendDiagnosticBridge");
const initial: RecoveryLineageView = {
groupId: "group",
@@ -62,5 +65,63 @@ await act(async () => { openButtons[1].click(); });
if (opened !== "/private/fork.jsonl") throw new Error("open action was not bound to the selected version");
await act(async () => root.unmount());
dom.window.close();
console.log(" PASS session version dialog hides persistence details");
// A rejected backend call must end as a toast, never as an unhandledrejection
// (which the global handler would otherwise paint as a full-screen crash).
const rejections: unknown[] = [];
const onProcessRejection = (reason: unknown) => { rejections.push(reason); };
process.on("unhandledRejection", onProcessRejection);
const diagnostics: string[] = [];
setFrontendDiagnosticSink((_source, type) => { diagnostics.push(type); });
window.go = {
main: {
App: {
ChooseRecoveryBranch: () => Promise.reject("selected branch is outside the recovery lineage"),
GetRecoveryLineage: async () => initial,
} as Partial<AppBindings> as AppBindings,
},
};
let closed = false;
const failing = createRoot(document.getElementById("root")!);
await act(async () => {
failing.render(
<LocaleProvider>
<ToastProvider>
<RecoveryLineageDialog
topic={{ scope: "global", topicId: "topic" }}
initial={initial}
onClose={() => { closed = true; }}
onChanged={() => {}}
onOpenVersion={() => Promise.reject(new Error("resume failed"))}
/>
</ToastProvider>
</LocaleProvider>,
);
});
const findButton = (label: string) => Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((button) => button.textContent?.trim() === label);
const errorToasts = () => Array.from(document.querySelectorAll(".toast--error .toast__text")).map((node) => node.textContent);
await act(async () => { findButton("Set as default version")!.click(); });
await act(async () => { await Promise.resolve(); });
if (!errorToasts().includes("Could not set the default version: selected branch is outside the recovery lineage")) {
throw new Error(`rejected default-version choice did not toast: ${JSON.stringify(errorToasts())}`);
}
if (!diagnostics.includes("session.recovery-choose-failed")) throw new Error("rejected default-version choice did not record a diagnostic");
if (findButton("Set as default version")?.disabled) throw new Error("dialog stayed busy after the rejected choice");
await act(async () => { findButton("Open this version")!.click(); });
await act(async () => { await Promise.resolve(); });
if (!errorToasts().includes("Could not open this version: resume failed")) {
throw new Error(`rejected open-version did not toast: ${JSON.stringify(errorToasts())}`);
}
if (closed) throw new Error("dialog closed although opening the version failed");
await act(async () => failing.unmount());
await new Promise((resolve) => setTimeout(resolve, 0));
process.off("unhandledRejection", onProcessRejection);
if (rejections.length > 0) throw new Error(`recovery dialog leaked unhandled rejections: ${JSON.stringify(rejections)}`);
console.log(" PASS session version dialog contains rejected backend calls as toasts");
dom.window.close();
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { GitBranch, Pencil, X } from "lucide-react";
import { app } from "../lib/bridge";
import { recordFrontendDiagnostic } from "../lib/frontendDiagnosticBridge";
import type { ProjectTopicKey } from "../lib/sessionCatalogTypes";
import type { RecoveryLineageMember, RecoveryLineageView } from "../lib/types";
import { useT } from "../lib/i18n";
@@ -20,6 +21,10 @@ function versionActivityAt(member: RecoveryLineageMember): number {
return member.lastActivityAt || member.createdAt || 0;
}
function failureText(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function RecoveryLineageDialog({ topic, initial, onClose, onChanged, onOpenVersion }: RecoveryLineageDialogProps) {
const t = useT();
const { showToast } = useToast();
@@ -44,6 +49,9 @@ export function RecoveryLineageDialog({ topic, initial, onClose, onChanged, onOp
try {
await app.ChooseRecoveryBranch({ ...topic, path });
await refresh();
} catch (error) {
recordFrontendDiagnostic("app", "session.recovery-choose-failed", { status: "error" });
showToast(t("recovery.chooseBranchFailed", { error: failureText(error) }), "error");
} finally {
setBusy(false);
}
@@ -55,6 +63,9 @@ export function RecoveryLineageDialog({ topic, initial, onClose, onChanged, onOp
try {
await onOpenVersion(member);
onClose();
} catch (error) {
recordFrontendDiagnostic("app", "session.recovery-open-failed", { status: "error" });
showToast(t("recovery.openVersionFailed", { error: failureText(error) }), "error");
} finally {
setBusy(false);
}
@@ -74,7 +85,7 @@ export function RecoveryLineageDialog({ topic, initial, onClose, onChanged, onOp
setEditingPath("");
await refresh();
} catch (error) {
showToast(error instanceof Error ? error.message : String(error), "error");
showToast(failureText(error), "error");
} finally {
setBusy(false);
}
+2 -14
View File
@@ -757,7 +757,7 @@ function paintPerformancePrompt(payload: CrashPayload, snapshot: PerformanceSnap
host.replaceChildren(title, body, actions, note);
}
function paint(payload: CrashPayload) {
export function paintCrashOverlay(payload: CrashPayload) {
let host = document.getElementById("crash-overlay");
if (!host) {
host = document.createElement("div");
@@ -783,7 +783,7 @@ function paint(payload: CrashPayload) {
}
export function reportCrash(label: string, err: unknown, extra?: string) {
paint(buildCrashPayload(label, err, extra));
paintCrashOverlay(buildCrashPayload(label, err, extra));
}
type GlobalCrashEventLike = Pick<Event, "defaultPrevented"> & {
@@ -1011,15 +1011,3 @@ export function installPerformancePressureMonitor() {
maybePromptForHeapPressure();
}, 1000);
}
export function installGlobalCrashHandlers() {
window.addEventListener("error", (e) => {
if (!shouldReportGlobalCrashEvent(e)) return;
const payload = buildCrashPayload("window.error", globalCrashReportReason(e));
if (isOpaqueScriptErrorEvent(e)) payload.fingerprintHint = opaqueScriptFingerprintHint();
paint(payload);
});
window.addEventListener("unhandledrejection", (e) => {
if (shouldReportGlobalCrashEvent(e)) reportCrash("unhandledrejection", e.reason);
});
}
@@ -0,0 +1,53 @@
// Window-level failure routing. Wails rejects a bound Go call with the bare
// error string, so a stackless rejection is an ordinary backend error that is
// contained and toasted; only faults carrying a stack reach the crash overlay.
import {
buildCrashPayload,
globalCrashReportReason,
isOpaqueScriptErrorEvent,
normalizeCrashError,
opaqueScriptFingerprintHint,
paintCrashOverlay,
reportCrash,
shouldReportGlobalCrashEvent,
} from "./crash";
import { recordFrontendDiagnostic } from "./frontendDiagnosticBridge";
export const RECOVERABLE_ERROR_EVENT = "reasonix:recoverable-error";
export type RecoverableErrorDetail = { message: string };
export function isRecoverableRejectionReason(reason: unknown): boolean {
if (typeof reason !== "object" || reason === null) return true;
const stack = (reason as { stack?: unknown }).stack;
return typeof stack !== "string" || stack.trim() === "";
}
export function onRecoverableError(cb: (detail: RecoverableErrorDetail) => void): () => void {
const handler = (e: Event) => cb((e as CustomEvent<RecoverableErrorDetail>).detail);
window.addEventListener(RECOVERABLE_ERROR_EVENT, handler);
return () => window.removeEventListener(RECOVERABLE_ERROR_EVENT, handler);
}
function containRecoverableRejection(e: PromiseRejectionEvent): boolean {
if (!isRecoverableRejectionReason(e.reason)) return false;
e.preventDefault();
recordFrontendDiagnostic("runtime", "unhandled-backend-rejection", { status: "error" });
const detail: RecoverableErrorDetail = { message: normalizeCrashError(e.reason).errorMessage };
window.dispatchEvent(new CustomEvent(RECOVERABLE_ERROR_EVENT, { detail }));
return true;
}
export function installGlobalCrashHandlers() {
window.addEventListener("error", (e) => {
if (!shouldReportGlobalCrashEvent(e)) return;
const payload = buildCrashPayload("window.error", globalCrashReportReason(e));
if (isOpaqueScriptErrorEvent(e)) payload.fingerprintHint = opaqueScriptFingerprintHint();
paintCrashOverlay(payload);
});
window.addEventListener("unhandledrejection", (e) => {
if (!shouldReportGlobalCrashEvent(e) || containRecoverableRejection(e)) return;
reportCrash("unhandledrejection", e.reason);
});
}
+2
View File
@@ -1366,7 +1366,9 @@ export const en = {
"recovery.versionPreviewEmpty": "No preview available for this version.",
"recovery.inUse": "in use",
"recovery.chooseBranch": "Set as default version",
"recovery.chooseBranchFailed": "Could not set the default version: {error}",
"recovery.openVersion": "Open this version",
"recovery.openVersionFailed": "Could not open this version: {error}",
"recovery.role.covered_copy": "covered by a fuller version",
"recovery.role.adopted": "full version",
"recovery.role.preferred": "default version",
+2
View File
@@ -1117,7 +1117,9 @@ export const zhTW: Record<DictKey, string> = {
"recovery.versionPreviewEmpty": "此版本暫無內容預覽。",
"recovery.inUse": "正在使用",
"recovery.chooseBranch": "設為預設版本",
"recovery.chooseBranchFailed": "設為預設版本失敗:{error}",
"recovery.openVersion": "開啟此版本",
"recovery.openVersionFailed": "開啟此版本失敗:{error}",
"recovery.role.covered_copy": "已被更完整版本覆蓋",
"recovery.role.adopted": "完整版本",
"recovery.role.preferred": "預設版本",
+2
View File
@@ -1367,7 +1367,9 @@ export const zh: Record<DictKey, string> = {
"recovery.versionPreviewEmpty": "此版本暂无内容预览。",
"recovery.inUse": "正在使用",
"recovery.chooseBranch": "设为默认版本",
"recovery.chooseBranchFailed": "设为默认版本失败:{error}",
"recovery.openVersion": "打开此版本",
"recovery.openVersionFailed": "打开此版本失败:{error}",
"recovery.role.covered_copy": "已被更完整版本覆盖",
"recovery.role.adopted": "完整版本",
"recovery.role.preferred": "默认版本",
+2 -1
View File
@@ -3,7 +3,8 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { installGlobalCrashHandlers, installPerformancePressureMonitor } from "./lib/crash";
import { installPerformancePressureMonitor } from "./lib/crash";
import { installGlobalCrashHandlers } from "./lib/globalCrashHandlers";
import { installWailsNonFileDragErrorSuppression } from "./lib/bridge";
import { installBreadcrumbConsoleHook } from "./lib/breadcrumbs";
import { installMessageSelectionCopy } from "./lib/messageSelectionCopy";
+9 -4
View File
@@ -437,12 +437,17 @@ func (a *App) ChooseRecoveryBranch(req RecoveryPreferenceRequest) error {
if chosen == "" {
return errors.New("selected branch is outside the recovery lineage")
}
defer a.lockRuntimeMutation("choose-recovery-branch")()
a.sessionRemovalMu.Lock()
defer a.sessionRemovalMu.Unlock()
if err := agent.SetRecoveryPreferred(paths, chosen); err != nil {
if err := func() error {
defer a.lockRuntimeMutation("choose-recovery-branch")()
a.sessionRemovalMu.Lock()
defer a.sessionRemovalMu.Unlock()
return agent.SetRecoveryPreferred(paths, chosen)
}(); err != nil {
return errors.New("could not save the recovery branch choice")
}
// The rescan reads session files and rewrites only the catalog projection,
// so it needs neither barrier; only the preference write above must stay
// atomic with respect to session removal.
if err := catalog.ReconcileDirectory(a.bootContext(), sessioncatalog.DirectoryTarget{Path: dir, Scope: req.Scope, WorkspaceRoot: req.WorkspaceRoot}); err != nil {
return errors.New("the branch choice was saved but the session catalog could not refresh")
}
+84
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"path/filepath"
"sync/atomic"
"testing"
"time"
@@ -160,3 +161,86 @@ func TestGetRecoveryLineageBindsRequestedPhysicalGroup(t *testing.T) {
t.Fatalf("ambiguous legacy lookup = %+v, want safe empty array", ambiguous)
}
}
func TestChooseRecoveryBranchPersistsPreferenceAndRefreshesOffBarrier(t *testing.T) {
isolateDesktopUserDirs(t)
ctx := context.Background()
dir := t.TempDir()
catalog, err := sessioncatalog.Open(ctx, sessioncatalog.Options{InMemory: true, DisableRepair: true})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = catalog.Close(context.Background()) })
root := filepath.Join(dir, "root.jsonl")
fork := filepath.Join(dir, "fork.jsonl")
save := func(path string, messages ...string) {
t.Helper()
session := agent.NewSession("system")
for index, message := range messages {
role := provider.RoleUser
if index%2 == 1 {
role = provider.RoleAssistant
}
session.Add(provider.Message{Role: role, Content: message})
}
if err := session.Save(path); err != nil {
t.Fatal(err)
}
}
save(root, "shared question", "shared answer", "root unique", "root answer")
save(fork, "shared question", "shared answer", "fork unique", "fork answer")
created := time.UnixMilli(100)
for path, meta := range map[string]agent.BranchMeta{
root: {ID: "root", Scope: "global", TopicID: "topic", TopicTitle: "Topic", CreatedAt: created, UpdatedAt: created},
fork: {ID: "fork", Scope: "global", TopicID: "topic", TopicTitle: "Topic", Recovered: true, ParentID: "root", RecoveryDepth: 1, CreatedAt: created.Add(time.Second), UpdatedAt: created.Add(time.Second)},
} {
if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil {
t.Fatal(err)
}
}
if err := catalog.ReconcileDirectory(ctx, sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
t.Fatal(err)
}
app := &App{tabs: map[string]*WorkspaceTab{}, detachedSessions: map[string]*WorkspaceTab{}}
app.sessionCatalog.Store(catalog)
var barrierReleased atomic.Bool
app.projectTreeChangedHook = func() {
if !app.runtimeRebuildMu.TryLock() {
return
}
app.runtimeRebuildMu.Unlock()
if app.sessionRemovalMu.TryLock() {
app.sessionRemovalMu.Unlock()
barrierReleased.Store(true)
}
}
key := ProjectTopicKey{Scope: "global", TopicID: "topic", Path: fork}
if view := app.GetRecoveryLineage(key); view.State != "diverged" {
t.Fatalf("lineage before choice = %+v, want diverged", view)
}
if err := app.ChooseRecoveryBranch(RecoveryPreferenceRequest{Scope: "global", TopicID: "topic", Path: fork}); err != nil {
t.Fatal(err)
}
if !barrierReleased.Load() {
t.Fatal("catalog refresh after the choice still ran under the runtime mutation barrier")
}
meta, ok, err := agent.LoadBranchMeta(fork)
if err != nil || !ok || !meta.RecoveryPreferred {
t.Fatalf("fork meta = %+v ok=%v err=%v, want a persisted preference", meta, ok, err)
}
view := app.GetRecoveryLineage(key)
if view.State != "preferred" || view.Unresolved != 0 {
t.Fatalf("lineage after choice = %+v, want preferred", view)
}
for _, member := range view.Members {
if member.Canonical != (member.Path == fork) {
t.Fatalf("member %+v, want only the chosen fork canonical", member)
}
}
err = app.ChooseRecoveryBranch(RecoveryPreferenceRequest{Scope: "global", TopicID: "topic", Path: filepath.Join(dir, "missing.jsonl")})
if err == nil || err.Error() != "selected branch is outside the recovery lineage" {
t.Fatalf("outside-lineage choice error = %v", err)
}
}