fix(desktop): isolate renderer failures and recover safely

Refs #312
This commit is contained in:
tianma-if committed 2026-08-28 12:22:29 +08:00
1 parent 0295bde6d5
commit c00e21fbde
12 files changed
+269 -11

No files matched your search

+4 -2
View File
@@ -90,6 +90,7 @@ let sidecarRestartAttempts = 0;
let sidecarRestartInFlight = false;
let localDataResetScheduled = false;
let rendererCrashDialogOpen = false;
let recoveredAfterAbnormalExit = false;
const updateCheckIntervalMs = 60 * 60 * 1_000;
const updateCheckFocusThrottleMs = 15 * 60 * 1_000;
const hasSingleInstanceLock = app.requestSingleInstanceLock();
@@ -886,8 +887,8 @@ app.whenReady().then(async () => {
await loadConfiguredApiBaseUrl();
await loadDesktopSessionToken();
app.setAsDefaultProtocolClient("edgeever");
const previousSessionWasActive = existsSync(crashMarkerPath());
void writeDiagnostic(previousSessionWasActive ? "session.recovered-after-abnormal-exit" : "session.started");
recoveredAfterAbnormalExit = existsSync(crashMarkerPath());
void writeDiagnostic(recoveredAfterAbnormalExit ? "session.recovered-after-abnormal-exit" : "session.started");
await writeFile(crashMarkerPath(), new Date().toISOString());
session.defaultSession.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
registerResourceProtocol();
@@ -931,6 +932,7 @@ app.whenReady().then(async () => {
});
ipcMain.on("desktop:api-base-url-sync", (event) => { event.returnValue = configuredApiBaseUrl; });
ipcMain.on("desktop:session-token-sync", (event) => { event.returnValue = desktopSessionToken; });
ipcMain.on("desktop:recovered-after-abnormal-exit-sync", (event) => { event.returnValue = recoveredAfterAbnormalExit; });
ipcMain.handle("desktop:copy-text", (_event, value) => {
if (typeof value !== "string") throw new Error("Clipboard value must be a string");
clipboard.writeText(value);
@@ -0,0 +1,13 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
describe("desktop abnormal-exit recovery", () => {
test("exposes the previous abnormal session to the renderer before workspace startup", () => {
const mainSource = readFileSync(new URL("./index.mjs", import.meta.url), "utf8");
const preloadSource = readFileSync(new URL("../preload/index.cjs", import.meta.url), "utf8");
expect(mainSource).toContain("recoveredAfterAbnormalExit = existsSync(crashMarkerPath())");
expect(mainSource).toContain('ipcMain.on("desktop:recovered-after-abnormal-exit-sync"');
expect(preloadSource).toContain('recoveredAfterAbnormalExit: ipcRenderer.sendSync("desktop:recovered-after-abnormal-exit-sync")');
});
});
+1
View File
@@ -3,6 +3,7 @@ const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("edgeeverDesktop", Object.freeze({
isAvailable: true,
canClearLocalData: ipcRenderer.sendSync("desktop:local-data-reset-available-sync"),
recoveredAfterAbnormalExit: ipcRenderer.sendSync("desktop:recovered-after-abnormal-exit-sync"),
sidecarStatus: () => ipcRenderer.invoke("desktop:sidecar-status"),
setAccountScope: (accountId) => ipcRenderer.invoke("desktop:set-account-scope", accountId),
apiBaseUrl: ipcRenderer.sendSync("desktop:api-base-url-sync"),
@@ -1,4 +1,5 @@
import React, { type ErrorInfo, type ReactNode } from "react";
import { markRendererRecoveryRequired } from "@/lib/renderer-recovery";
type RendererErrorDetails = {
kind: "react-error";
@@ -30,6 +31,7 @@ export class DesktopRendererErrorBoundary extends React.Component<{ children: Re
}
componentDidCatch(error: Error, info: ErrorInfo) {
markRendererRecoveryRequired();
const details: RendererErrorDetails = {
kind: "react-error",
message: error.message,
@@ -65,11 +67,11 @@ export class DesktopRendererErrorBoundary extends React.Component<{ children: Re
<p className="mt-2 text-sm leading-6 text-slate-600">
{desktop
? (zh
? "问题已经记录到这台设备。你可以重新加载继续使用,并在提交前检查脱敏后的 GitHub Issue。"
: "The problem was recorded on this device. You can reload to continue and review the redacted GitHub Issue before submitting it.")
? "问题已经记录到这台设备。重新加载后会进入安全启动模式,不再自动打开刚才的笔记。"
: "The problem was recorded on this device. Reloading will enter safe startup mode instead of reopening the previous note.")
: (zh
? "你可以重新加载继续使用。如果问题仍然出现,请检查浏览器控制台,并在提交 GitHub Issue 前移除敏感信息。"
: "Reload to continue. If the problem persists, check the browser console and remove sensitive information before submitting a GitHub Issue.")}
? "安全重新加载后不会自动打开刚才的笔记。如果问题仍然出现,请检查浏览器控制台。"
: "A safe reload will avoid reopening the previous note. If the problem persists, check the browser console.")}
</p>
<div className="mt-5 flex flex-wrap gap-3">
<button
@@ -77,7 +79,7 @@ export class DesktopRendererErrorBoundary extends React.Component<{ children: Re
type="button"
onClick={() => window.location.reload()}
>
{zh ? "重新加载" : "Reload"}
{zh ? "安全重新加载" : "Reload safely"}
</button>
{desktop ? (
<button
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
describe("note editor failure containment", () => {
test("isolates editor failures below the workspace shell", () => {
const workspaceSource = readFileSync(new URL("./WorkspaceApp.tsx", import.meta.url), "utf8");
expect(workspaceSource).toContain("<EditorPaneErrorBoundary");
expect(workspaceSource).toContain("<EditorPane\n");
expect(workspaceSource.indexOf("<EditorPaneErrorBoundary")).toBeLessThan(workspaceSource.indexOf("<EditorPane\n"));
});
test("does not auto-open a note while renderer recovery is active", () => {
const workspaceSource = readFileSync(new URL("./WorkspaceApp.tsx", import.meta.url), "utf8");
const rootBoundarySource = readFileSync(new URL("./DesktopRendererErrorBoundary.tsx", import.meta.url), "utf8");
expect(rootBoundarySource).toContain("markRendererRecoveryRequired()");
expect(workspaceSource).toContain("rendererRecoveryMode ? null : selectedMemoId ?? memos[0]?.id ?? null");
expect(workspaceSource).toContain("if (rendererRecoveryMode) {");
expect(workspaceSource).toContain("<EditorRecoveryPane />");
});
});
@@ -0,0 +1,98 @@
import React, { type ErrorInfo, type ReactNode } from "react";
import { clearRendererRecoveryRequired, markRendererRecoveryRequired } from "@/lib/renderer-recovery";
type Props = {
children: ReactNode;
resetKey: string | null;
onBackToList: () => void;
};
type State = {
error: Error | null;
};
const isChineseInterface = () => navigator.language.toLowerCase().startsWith("zh");
export class EditorPaneErrorBoundary extends React.Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: unknown): State {
return { error: error instanceof Error ? error : new Error(String(error)) };
}
componentDidCatch(error: Error, info: ErrorInfo) {
markRendererRecoveryRequired();
const details = {
kind: "react-error" as const,
message: error.message,
stack: error.stack ?? "",
componentStack: info.componentStack ?? "",
};
console.error("Caught note editor error", error, info.componentStack);
void window.edgeeverDesktop?.recordRendererError(details).catch(() => undefined);
}
componentDidUpdate(previousProps: Props) {
if (this.state.error && previousProps.resetKey !== this.props.resetKey) {
clearRendererRecoveryRequired();
this.setState({ error: null });
}
}
private retry = () => {
clearRendererRecoveryRequired();
this.setState({ error: null });
};
render() {
if (!this.state.error) return this.props.children;
const zh = isChineseInterface();
return (
<main className="flex h-full min-h-0 items-center justify-center bg-slate-50 p-6 text-slate-900">
<section className="w-full max-w-lg rounded-2xl border border-slate-200 bg-white p-6 shadow-sm" role="alert">
<div className="mb-4 flex h-11 w-11 items-center justify-center rounded-full bg-amber-50 text-xl text-amber-700">!</div>
<h1 className="text-lg font-semibold">{zh ? "这条笔记暂时无法显示" : "This note cannot be displayed"}</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">
{zh
? "异常已被限制在当前笔记,笔记列表和应用其他功能仍可继续使用。请选择另一条笔记,或重试当前笔记。"
: "The failure was isolated to this note. The note list and the rest of the app remain available. Choose another note or retry this one."}
</p>
<div className="mt-5 flex flex-wrap gap-3">
<button
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
type="button"
onClick={this.props.onBackToList}
>
{zh ? "返回笔记列表" : "Back to note list"}
</button>
<button
className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500"
type="button"
onClick={this.retry}
>
{zh ? "重试当前笔记" : "Retry this note"}
</button>
</div>
</section>
</main>
);
}
}
export const EditorRecoveryPane = () => {
const zh = isChineseInterface();
return (
<main className="flex h-full min-h-0 items-center justify-center bg-slate-50 p-6 text-slate-900">
<section className="w-full max-w-lg rounded-2xl border border-slate-200 bg-white p-6 shadow-sm" role="status">
<div className="mb-4 flex h-11 w-11 items-center justify-center rounded-full bg-emerald-50 text-xl text-emerald-700">✓</div>
<h1 className="text-lg font-semibold">{zh ? "已进入安全启动模式" : "Safe startup mode is active"}</h1>
<p className="mt-2 text-sm leading-6 text-slate-600">
{zh
? "EdgeEver 没有重新打开上次出错的笔记。请从左侧列表选择其他笔记继续使用;选择后会自动退出安全模式。"
: "EdgeEver did not reopen the note that failed last time. Choose another note from the list to continue; safe mode will then turn off automatically."}
</p>
</section>
</main>
);
};
+29 -3
View File
@@ -94,6 +94,8 @@ import { useWorkspacePreferences } from "@/hooks/useWorkspacePreferences";
import { useWorkspaceSelection } from "@/hooks/useWorkspaceSelection";
import { useWorkspaceQueuedSync } from "@/hooks/useWorkspaceQueuedSync";
import { EdgeEverPluginHost } from "@/lib/plugins/plugin-host";
import { clearRendererRecoveryRequired, isRendererRecoveryRequired } from "@/lib/renderer-recovery";
import { EditorPaneErrorBoundary, EditorRecoveryPane } from "./EditorPaneErrorBoundary";
const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches;
const PULL_TO_REFRESH_TRIGGER_PX = 72;
@@ -685,6 +687,9 @@ export const WorkspaceApp = ({
const isInitialAiPromptsRoute = route.isAiPrompts;
const isInitialMobileEditorReturn = Boolean(route.mobileEditorReturnMemoId);
const isTrashRoute = route.isTrash;
const [rendererRecoveryMode, setRendererRecoveryMode] = useState(() =>
Boolean(window.edgeeverDesktop?.recoveredAfterAbnormalExit) || isRendererRecoveryRequired()
);
const [activePane, setActivePane] = useState<Pane>(() => ((isInitialSettingsRoute || isInitialPluginsRoute || isInitialTemplatesRoute || isInitialAiPromptsRoute) && !isInitialMobileEditorReturn ? "editor" : "memos"));
const [memoView, setMemoView] = useState<MemoView>(() => (isTrashRoute ? "trash" : "notebook"));
const {
@@ -1371,11 +1376,15 @@ export const WorkspaceApp = ({
const previousMemoId = selectedMemoIndex > 0 ? memos[selectedMemoIndex - 1]?.id : null;
const nextMemoId =
selectedMemoIndex >= 0 && selectedMemoIndex < memos.length - 1 ? memos[selectedMemoIndex + 1]?.id : null;
const detailMemoId = selectedMemoId ?? memos[0]?.id ?? null;
const detailMemoId = rendererRecoveryMode ? null : selectedMemoId ?? memos[0]?.id ?? null;
useEffect(() => {
const selectedMemoInList = selectedMemoId ? memos.some((memo) => memo.id === selectedMemoId) : false;
if (rendererRecoveryMode) {
return;
}
if (creatingMemoSelectionRef.current || pendingCreatedMemoIdRef.current) {
return;
}
@@ -1402,7 +1411,13 @@ export const WorkspaceApp = ({
if (!selectedMemoId || !selectedMemoInList) {
setSelectedMemoId(memos[0].id);
}
}, [createdMemoEditId, memos, selectedMemoId]);
}, [createdMemoEditId, memos, rendererRecoveryMode, selectedMemoId]);
useEffect(() => {
if (!rendererRecoveryMode || !selectedMemoId) return;
clearRendererRecoveryRequired();
setRendererRecoveryMode(false);
}, [rendererRecoveryMode, selectedMemoId]);
const memoQuery = useQuery({
queryKey: detailMemoId ? memoDetailQueryKey(detailMemoId, memoView) : ["memo", detailMemoId, memoView],
@@ -3128,8 +3143,18 @@ export const WorkspaceApp = ({
<AiPromptsPane onClose={handleCloseAiPrompts} />
) : rightView === "evernote-migration" ? (
<EvernoteImportGuidePane onClose={() => setRightView("settings")} />
) : rendererRecoveryMode ? (
<EditorRecoveryPane />
) : (
<EditorPane
<EditorPaneErrorBoundary
resetKey={selectedMemo?.id ?? selectedMemoId}
onBackToList={() => {
setRendererRecoveryMode(true);
setSelectedMemoId(null);
setActivePane("memos");
}}
>
<EditorPane
memo={selectedMemo}
repository={repository}
pluginHost={pluginHost}
@@ -3224,6 +3249,7 @@ export const WorkspaceApp = ({
onMobileDefaultEditConsumed={handleMobileDefaultEditConsumed}
onSaveAsTemplate={handleSaveAsTemplate}
/>
</EditorPaneErrorBoundary>
)}
</m.div>
</Suspense>
@@ -0,0 +1,42 @@
import { describe, expect, test } from "bun:test";
import {
RENDERER_RECOVERY_STORAGE_KEY,
clearRendererRecoveryRequired,
isRendererRecoveryRequired,
markRendererRecoveryRequired,
} from "./renderer-recovery.ts";
const createStorage = () => {
const values = new Map();
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
removeItem: (key) => values.delete(key),
};
};
describe("renderer recovery marker", () => {
test("persists recovery until the user leaves safe mode", () => {
const storage = createStorage();
expect(isRendererRecoveryRequired(storage)).toBe(false);
markRendererRecoveryRequired(storage);
expect(storage.getItem(RENDERER_RECOVERY_STORAGE_KEY)).toBe("1");
expect(isRendererRecoveryRequired(storage)).toBe(true);
clearRendererRecoveryRequired(storage);
expect(isRendererRecoveryRequired(storage)).toBe(false);
});
test("never throws when browser storage is unavailable", () => {
const unavailableStorage = {
getItem() { throw new Error("blocked"); },
setItem() { throw new Error("blocked"); },
removeItem() { throw new Error("blocked"); },
};
expect(isRendererRecoveryRequired(unavailableStorage)).toBe(false);
expect(() => markRendererRecoveryRequired(unavailableStorage)).not.toThrow();
expect(() => clearRendererRecoveryRequired(unavailableStorage)).not.toThrow();
});
});
+35
View File
@@ -0,0 +1,35 @@
export const RENDERER_RECOVERY_STORAGE_KEY = "edgeever.renderer-recovery-required";
type RecoveryStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
const getBrowserStorage = (): RecoveryStorage | null => {
try {
return typeof window === "undefined" ? null : window.localStorage;
} catch {
return null;
}
};
export const isRendererRecoveryRequired = (storage: RecoveryStorage | null = getBrowserStorage()) => {
try {
return storage?.getItem(RENDERER_RECOVERY_STORAGE_KEY) === "1";
} catch {
return false;
}
};
export const markRendererRecoveryRequired = (storage: RecoveryStorage | null = getBrowserStorage()) => {
try {
storage?.setItem(RENDERER_RECOVERY_STORAGE_KEY, "1");
} catch {
// Recovery must never turn a renderer failure into another failure.
}
};
export const clearRendererRecoveryRequired = (storage: RecoveryStorage | null = getBrowserStorage()) => {
try {
storage?.removeItem(RENDERER_RECOVERY_STORAGE_KEY);
} catch {
// Restricted storage should not prevent the user from opening another note.
}
};
+1
View File
@@ -16,6 +16,7 @@ declare const __EDGEEVER_DEVELOPMENT_PROFILE__: "" | "local" | "demo";
interface EdgeEverDesktopBridge {
isAvailable: boolean;
canClearLocalData: boolean;
recoveredAfterAbnormalExit: boolean;
apiBaseUrl: string;
setApiBaseUrl(value: string): Promise<string>;
getSessionToken(): string;
+1 -1
View File
@@ -58,7 +58,7 @@
"release": "bun scripts/release.mjs",
"publish:stores": "bun scripts/store-delivery.mjs",
"test:desktop:sidecar": "bun scripts/test-desktop-sidecar.mjs",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"test:desktop": "bun test apps/desktop/src/main/account-scope.test.mjs apps/desktop/src/main/clipboard-write.test.mjs apps/desktop/src/main/desktop-diagnostics.test.mjs apps/desktop/src/main/diagnostic-log.test.mjs apps/desktop/src/main/file-association.test.mjs apps/desktop/src/main/file-permissions.test.mjs apps/desktop/src/main/installation-location.test.mjs apps/desktop/src/main/local-data-reset.test.mjs apps/desktop/src/main/renderer-recovery.test.mjs apps/desktop/src/main/resource-request.test.mjs apps/desktop/src/main/resource-url.test.mjs apps/desktop/src/main/staged-resource.test.mjs apps/desktop/src/main/tray-icon.test.mjs apps/desktop/src/main/user-data-directory.test.mjs apps/desktop/src/main/window-open-policy.test.mjs apps/desktop/src/main/window-visibility.test.mjs apps/web/src/lib/api.test.mjs apps/web/src/lib/app-page-path.test.mjs apps/web/src/lib/clipboard.test.mjs apps/web/src/lib/desktop-repository.test.mjs apps/web/src/lib/desktop-resources.test.mjs apps/web/src/lib/desktop-sync.test.mjs apps/web/src/lib/local-mirror.test.mjs apps/web/src/lib/repository.test.mjs apps/web/src/lib/runtime.test.mjs apps/web/src/lib/sync-queue.test.mjs && bun run test:desktop:sidecar",
"verify:desktop-renderer": "bun scripts/verify-desktop-renderer.mjs",
"verify:desktop-package": "bun scripts/verify-desktop-package.mjs",
"build:worker": "bun scripts/build-cloudflare-worker.mjs",
+16
View File
@@ -0,0 +1,16 @@
import { expect, test } from "@playwright/test";
test("starts at the note list after a renderer failure instead of reopening the previous note", async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("edgeever.renderer-recovery-required", "1");
});
await page.goto("/");
await expect(page.getByRole("status").getByText("已进入安全启动模式")).toBeVisible();
const firstMemo = page.locator("[data-memo-id]").first();
await expect(firstMemo).toBeVisible();
await firstMemo.click();
await expect(page.getByText("已进入安全启动模式")).toHaveCount(0);
await expect.poll(() => page.evaluate(() => window.localStorage.getItem("edgeever.renderer-recovery-required"))).toBeNull();
});