From e7eaa73ead449c14bcff1691bfe38c0c8e0079bc Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:49:34 +0000 Subject: [PATCH 1/2] fix(settings): read a pasted provider key before its state update runs Pasting a MiniMax API key into Settings -> Agents -> Provider API keys turned the window black. The key field's onChange read event.currentTarget.value inside the setDrafts((current) => ...) updater. React runs that updater later, during render, whenever an earlier update of the component is still pending (a paste over a character already in the field, the second keystroke of fast typing). By then the event has finished dispatching and currentTarget is null, so the render threw "Cannot read properties of null (reading 'value')" and React unmounted the whole root. The renderer process stayed alive with an empty #root. Key length and shape are not the cause: a 120-character key pasted over a pending edit breaks the same way. The handler now reads the value while the event dispatches and passes it to the updater. No other renderer updater reads an event (checked with an AST scan over src/renderer). Tests: tests/provider-secrets-settings.test.mjs bundles the real component against a React stand-in whose updaters run after dispatch and pastes fake sk-cp-/sk-api- keys of 120 to 10 000 characters, with a trailing newline and space, over a pending edit (fails before this change with the same TypeError); a line-level guard keeps event reads out of state updaters in the renderer. --- .../settings/ProviderSecretsSettings.tsx | 7 +- tests/provider-secrets-settings.test.mjs | 122 ++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 tests/provider-secrets-settings.test.mjs 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/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, []); +}); From a2233181832cf52a86180a0ab3b930932189aa04 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:49:34 +0000 Subject: [PATCH 2/2] fix(renderer): bring the window back after an uncaught render error An error thrown while React renders unmounts the whole root, but the renderer process lives on, so render-process-gone never fires and the main process's crash reload never runs: the window stays black until the app is restarted. That is what the broken provider key field did. The React root now handles onUncaughtError. The first such error logs it with its component stack and reloads the application surface in place; sessions live in the main process and survive the reload, as after a renderer crash. A second one within 30 s (or with no session storage to remember the reload) shows a static page with a Reload button instead of reloading in a loop. Tests: tests/uncaught-error-recovery.test.mjs (reload, cooldown, clock change, no storage, root wiring). Checked in a hidden app: an injected render error reloads to a usable Settings screen, a second one within the cooldown shows the recovery page; forcefullyCrashRenderer still reloads through render-process-gone. --- src/renderer/src/lib/i18n.ts | 6 ++ src/renderer/src/lib/uncaughtErrorRecovery.ts | 93 +++++++++++++++++++ src/renderer/src/main.tsx | 8 +- src/renderer/src/styles/app.css | 4 + tests/uncaught-error-recovery.test.mjs | 64 +++++++++++++ 5 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/lib/uncaughtErrorRecovery.ts create mode 100644 tests/uncaught-error-recovery.test.mjs 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/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); +});