diff --git a/src/renderer/src/features/settings/ProviderSecretsSettings.tsx b/src/renderer/src/features/settings/ProviderSecretsSettings.tsx index 56a10e2e..1f6fcec1 100644 --- a/src/renderer/src/features/settings/ProviderSecretsSettings.tsx +++ b/src/renderer/src/features/settings/ProviderSecretsSettings.tsx @@ -84,7 +84,12 @@ export function ProviderSecretsSettings({ locale }: ProviderSecretsSettingsProps spellCheck={false} placeholder={secretId} aria-label={`${SECRET_LABELS[secretId]} ${secretId}`} - onChange={(event) => setDrafts((current) => ({ ...current, [secretId]: event.currentTarget.value }))} + onChange={(event) => { + // Read the value while the event is dispatching: React may run the updater + // later (a paste over a pending edit), when currentTarget is already null. + const value = event.currentTarget.value; + setDrafts((current) => ({ ...current, [secretId]: value })); + }} onKeyDown={(event) => { if (event.key === "Enter") void save(secretId); }} diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index a735ec3e..4789a25f 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -269,6 +269,9 @@ const ru = { providerSecretNotConfigured: "Не задан", providerSecretSave: "Сохранить", providerSecretClear: "Удалить", + rendererRecoveryTitle: "Окно CanvasTTY столкнулось с ошибкой", + rendererRecoveryText: "Сессии продолжают работать. Перезагрузите окно, чтобы вернуться к ним.", + rendererRecoveryReload: "Перезагрузить окно", apiProfiles: "API-профили", apiProfilesDescription: "Бэкенды для CLI с поддержкой BYOK. Профиль — не агент: он лишь задаёт endpoint и ключ для совместимых рантаймов.", apiProfileSave: "Сохранить", @@ -937,6 +940,9 @@ const en: Record = { providerSecretNotConfigured: "Not set", providerSecretSave: "Save", providerSecretClear: "Remove", + rendererRecoveryTitle: "The CanvasTTY window hit an error", + rendererRecoveryText: "Your sessions are still running. Reload the window to get back to them.", + rendererRecoveryReload: "Reload window", apiProfiles: "API profiles", apiProfilesDescription: "Backends for BYOK-capable CLIs. A profile is not an agent: it only supplies an endpoint and key reference to compatible runtimes.", apiProfileSave: "Save", diff --git a/src/renderer/src/lib/uncaughtErrorRecovery.ts b/src/renderer/src/lib/uncaughtErrorRecovery.ts new file mode 100644 index 00000000..a04befe3 --- /dev/null +++ b/src/renderer/src/lib/uncaughtErrorRecovery.ts @@ -0,0 +1,93 @@ +import type { LocaleId } from "../../../shared/contracts"; +import { t } from "./i18n"; + +// An error thrown while React renders, and not caught by a component, unmounts the whole root: the renderer +// process stays alive, so the main process's render-process-gone recovery never runs, and the window stays +// black until the app is restarted. The root reports such errors here. The first one reloads the +// application surface in place (sessions live in the main process and survive it, as after a renderer +// crash). A second one within the cooldown would only loop, so it gets a static page with a Reload button. + +export const UNCAUGHT_ERROR_RELOAD_KEY = "canvastty.uncaughtErrorReloadAt"; +export const UNCAUGHT_ERROR_RELOAD_COOLDOWN_MS = 30_000; + +export interface UncaughtErrorRecoveryHost { + now(): number; + readLastReloadAt(): number | null; + /** False when the reload time cannot be remembered: reloading then could loop forever. */ + writeLastReloadAt(at: number): boolean; + reload(): void; + showRecoveryPage(): void; +} + +export type UncaughtErrorRecovery = "reload" | "recovery-page"; + +export function recoverFromUncaughtError(host: UncaughtErrorRecoveryHost): UncaughtErrorRecovery { + const now = host.now(); + const last = host.readLastReloadAt(); + if (last !== null && now >= last && now - last < UNCAUGHT_ERROR_RELOAD_COOLDOWN_MS) { + host.showRecoveryPage(); + return "recovery-page"; + } + if (!host.writeLastReloadAt(now)) { + host.showRecoveryPage(); + return "recovery-page"; + } + host.reload(); + return "reload"; +} + +function recoveryLocale(): LocaleId { + return navigator.language.toLowerCase().startsWith("ru") ? "ru" : "en"; +} + +/** Plain DOM, no React: the React root is what just failed. */ +function showRecoveryPage(container: HTMLElement): void { + const locale = recoveryLocale(); + const page = document.createElement("div"); + page.className = "renderer-recovery"; + page.setAttribute("role", "alert"); + const title = document.createElement("strong"); + title.textContent = t(locale, "rendererRecoveryTitle"); + const text = document.createElement("p"); + text.textContent = t(locale, "rendererRecoveryText"); + const button = document.createElement("button"); + button.type = "button"; + button.textContent = t(locale, "rendererRecoveryReload"); + button.addEventListener("click", () => window.location.reload()); + page.append(title, text, button); + container.replaceChildren(page); +} + +function sessionStore(): Storage | null { + try { + return window.sessionStorage; + } catch { + return null; + } +} + +export function handleUncaughtRenderError(container: HTMLElement, error: unknown, componentStack?: string): void { + console.error("CanvasTTY hit an uncaught render error; recovering the application surface.", error, componentStack ?? ""); + const storage = sessionStore(); + recoverFromUncaughtError({ + now: () => Date.now(), + readLastReloadAt: () => { + try { + const value = Number(storage?.getItem(UNCAUGHT_ERROR_RELOAD_KEY) ?? Number.NaN); + return Number.isFinite(value) ? value : null; + } catch { + return null; + } + }, + writeLastReloadAt: (at) => { + try { + storage?.setItem(UNCAUGHT_ERROR_RELOAD_KEY, String(at)); + return storage?.getItem(UNCAUGHT_ERROR_RELOAD_KEY) === String(at); + } catch { + return false; + } + }, + reload: () => window.location.reload(), + showRecoveryPage: () => showRecoveryPage(container) + }); +} diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 12fa92d9..99b49521 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -2,11 +2,17 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "@xterm/xterm/css/xterm.css"; import { App } from "./App"; +import { handleUncaughtRenderError } from "./lib/uncaughtErrorRecovery"; import "./styles/tokens.css"; import "./styles/app.css"; import "./styles/patterns.css"; -createRoot(document.getElementById("root")!).render( +const container = document.getElementById("root")!; +createRoot(container, { + // React unmounts the whole tree on an uncaught render error while the renderer process lives on: + // without this the window stays black and the main process has no crash to recover from. + onUncaughtError: (error, errorInfo) => handleUncaughtRenderError(container, error, errorInfo.componentStack) +}).render( diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index ba8e980d..01c4f7da 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -1188,3 +1188,7 @@ button { border: 0; } .terminal-card__confirm span { font-weight: 500; line-height: 1.35; } .terminal-card__confirm-actions { display: flex; gap: 6px; } .terminal-card__confirm-actions button { padding: 4px 10px; border-radius: 7px; color: white; background: rgba(255,255,255,.12); font: inherit; cursor: pointer; } +.renderer-recovery { position: fixed; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; padding: 24px; background: #353442; color: #f8f7f1; text-align: center; } +.renderer-recovery strong { font-size: 16px; } +.renderer-recovery p { margin: 0; max-width: 420px; color: rgba(255, 255, 255, 0.7); font-size: 13px; line-height: 1.5; } +.renderer-recovery button { padding: 8px 16px; border: 0; border-radius: 10px; background: #b8cf99; color: #30313d; font: inherit; font-weight: 700; cursor: pointer; } diff --git a/tests/provider-secrets-settings.test.mjs b/tests/provider-secrets-settings.test.mjs new file mode 100644 index 00000000..91f71b34 --- /dev/null +++ b/tests/provider-secrets-settings.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +// The provider key fields in Settings, driven the way React drives them. React runs a functional state +// updater later than the change handler whenever an earlier update of that component is still pending +// (the second keystroke, or a paste over text already in the field). By then the synthetic event has +// finished dispatching and `event.currentTarget` is null. An updater that reads the event then throws +// during render, and React unmounts the whole application: the window turns black. +// +// This harness bundles the real component against a minimal React stand-in whose state updaters always +// run after dispatch, which is the ordering that broke the MiniMax key field in the packaged app. +const FAKE_REACT = ` +let state = []; +let cursor = 0; +const queue = []; +export function useState(initial) { + const index = cursor++; + if (!(index in state)) state[index] = typeof initial === "function" ? initial() : initial; + return [state[index], (update) => { queue.push([index, update]); }]; +} +export function useEffect() {} +export function jsx(type, props) { return { type, props }; } +export const jsxs = jsx; +export const Fragment = "fragment"; +export function __render(component, props) { cursor = 0; return component(props); } +export function __flush() { + for (const [index, update] of queue.splice(0)) state[index] = typeof update === "function" ? update(state[index]) : update; +} +export function __reset() { state = []; cursor = 0; queue.length = 0; } +export default { useState, useEffect }; +`; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const { outputFiles } = await build({ + stdin: { + contents: 'export { ProviderSecretsSettings } from "./src/renderer/src/features/settings/ProviderSecretsSettings.tsx"; export { __render, __flush, __reset } from "react";', + resolveDir: root, + loader: "ts" + }, + jsx: "automatic", + bundle: true, + platform: "node", + format: "esm", + write: false, + plugins: [{ + name: "fake-react", + setup(builder) { + builder.onResolve({ filter: /^react(\/jsx-runtime)?$/ }, () => ({ path: "react", namespace: "fake-react" })); + builder.onLoad({ filter: /.*/, namespace: "fake-react" }, () => ({ contents: FAKE_REACT, loader: "js" })); + } + }] +}); +const { ProviderSecretsSettings, __render, __flush, __reset } = await import( + `data:text/javascript;base64,${Buffer.from(outputFiles[0].contents).toString("base64")}` +); + +function findAll(node, predicate, found = []) { + if (Array.isArray(node)) { for (const child of node) findAll(child, predicate, found); return found; } + if (!node || typeof node !== "object") return found; + if (predicate(node)) found.push(node); + findAll(node.props?.children, predicate, found); + return found; +} +const keyField = (tree) => findAll(tree, (node) => node.type === "input" && /MINIMAX_API_KEY/u.test(node.props["aria-label"] ?? ""))[0]; + +/** One React change event: currentTarget is the field while the handler runs, null once dispatch ends. */ +function change(field, value) { + const element = { value }; + const event = { currentTarget: element, target: element }; + field.props.onChange(event); + event.currentTarget = null; +} + +// MiniMax keys are `sk-cp-` / `sk-api-` + a long [A-Za-z0-9_-] body. These are FAKE keys of that shape. +function fakeKey(length, prefix = "sk-cp-") { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-"; + let seed = length; + let out = prefix; + while (out.length < length) { seed = (seed * 1103515245 + 12345) >>> 0; out += alphabet[(seed >>> 8) % alphabet.length]; } + return out; +} + +test("pasting over a provider key draft keeps the settings rendered, for keys of any length", () => { + const keys = [ + fakeKey(120), fakeKey(200, "sk-api-"), fakeKey(250), fakeKey(500), fakeKey(2_000), fakeKey(10_000), + `${fakeKey(180)}\n`, `${fakeKey(180)} ` + ]; + __reset(); + const props = { locale: "en" }; + let tree = __render(ProviderSecretsSettings, props); + const started = performance.now(); + for (const key of keys) { + // A first character in the field, then the paste replacing it: two updates before one render. + change(keyField(tree), key.slice(0, 1)); + change(keyField(tree), key); + assert.doesNotThrow(__flush, `a ${key.length}-character key must not break the render`); + tree = __render(ProviderSecretsSettings, props); + assert.equal(keyField(tree).props.value, key); + const saveButton = findAll(tree, (node) => node.type === "button" && node.props.children === "Save") + .find((button) => button.props.disabled === false); + assert.ok(saveButton, "a pasted key enables its Save button"); + } + assert.ok(performance.now() - started < 1_000, "typing and rendering long keys stays fast"); +}); + +test("renderer state updaters never read a React event after its dispatch", async () => { + // Static guard for the same bug class anywhere in the renderer, one line at a time: + // `setX((prev) => ... event.currentTarget ...)`. Read the value first, then hand it to the updater. + const directory = join(root, "src", "renderer", "src"); + const files = (await readdir(directory, { recursive: true })).filter((file) => /\.tsx?$/u.test(file)); + const offenders = []; + for (const file of files) { + const source = await readFile(join(directory, file), "utf8"); + const pattern = /\bset[A-Z]\w*\(\s*\(?\s*(\w+)?\s*\)?\s*=>[^;\n]*?\b(event|e|ev)\.(currentTarget|target)\b/gu; + for (const match of source.matchAll(pattern)) offenders.push(`${file}: ${match[0].slice(0, 120)}`); + } + assert.deepEqual(offenders, []); +}); diff --git a/tests/uncaught-error-recovery.test.mjs b/tests/uncaught-error-recovery.test.mjs new file mode 100644 index 00000000..950289ce --- /dev/null +++ b/tests/uncaught-error-recovery.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +// An uncaught render error unmounts the React root but leaves the renderer process alive, so the main +// process's render-process-gone reload never fires and the window stays black. The root recovers itself. +const { outputFiles } = await build({ + entryPoints: [fileURLToPath(new URL("../src/renderer/src/lib/uncaughtErrorRecovery.ts", import.meta.url))], + bundle: true, + platform: "node", + format: "esm", + write: false +}); +const { + recoverFromUncaughtError, + UNCAUGHT_ERROR_RELOAD_COOLDOWN_MS +} = await import(`data:text/javascript;base64,${Buffer.from(outputFiles[0].contents).toString("base64")}`); + +function host({ now, last = null, remembers = true }) { + const calls = []; + return { + calls, + now: () => now, + readLastReloadAt: () => last, + writeLastReloadAt: (at) => { calls.push(`write ${at}`); return remembers; }, + reload: () => calls.push("reload"), + showRecoveryPage: () => calls.push("page") + }; +} + +test("the first uncaught render error reloads the application surface", () => { + const h = host({ now: 1_000_000 }); + assert.equal(recoverFromUncaughtError(h), "reload"); + assert.deepEqual(h.calls, ["write 1000000", "reload"]); +}); + +test("a second uncaught render error inside the cooldown shows the recovery page instead of looping", () => { + const h = host({ now: 1_000_000, last: 1_000_000 - UNCAUGHT_ERROR_RELOAD_COOLDOWN_MS + 1 }); + assert.equal(recoverFromUncaughtError(h), "recovery-page"); + assert.deepEqual(h.calls, ["page"]); +}); + +test("an error after the cooldown reloads again", () => { + const h = host({ now: 1_000_000, last: 1_000_000 - UNCAUGHT_ERROR_RELOAD_COOLDOWN_MS }); + assert.equal(recoverFromUncaughtError(h), "reload"); +}); + +test("a reload time in the future (clock change) does not block the reload", () => { + const h = host({ now: 1_000_000, last: 2_000_000 }); + assert.equal(recoverFromUncaughtError(h), "reload"); +}); + +test("without storage for the reload time the recovery page is shown, never an unbounded reload loop", () => { + const h = host({ now: 1_000_000, remembers: false }); + assert.equal(recoverFromUncaughtError(h), "recovery-page"); + assert.deepEqual(h.calls, ["write 1000000", "page"]); +}); + +test("the React root routes uncaught render errors to the recovery", async () => { + const source = await readFile(new URL("../src/renderer/src/main.tsx", import.meta.url), "utf8"); + assert.match(source, /createRoot\(container, \{\s*[^}]*onUncaughtError: \(error, errorInfo\) => handleUncaughtRenderError\(container, error/su); +});