Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}}
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,9 @@ const ru = {
providerSecretNotConfigured: "Не задан",
providerSecretSave: "Сохранить",
providerSecretClear: "Удалить",
rendererRecoveryTitle: "Окно CanvasTTY столкнулось с ошибкой",
rendererRecoveryText: "Сессии продолжают работать. Перезагрузите окно, чтобы вернуться к ним.",
rendererRecoveryReload: "Перезагрузить окно",
apiProfiles: "API-профили",
apiProfilesDescription: "Бэкенды для CLI с поддержкой BYOK. Профиль — не агент: он лишь задаёт endpoint и ключ для совместимых рантаймов.",
apiProfileSave: "Сохранить",
Expand Down Expand Up @@ -937,6 +940,9 @@ const en: Record<keyof typeof ru, string> = {
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",
Expand Down
93 changes: 93 additions & 0 deletions src/renderer/src/lib/uncaughtErrorRecovery.ts
Original file line number Diff line number Diff line change
@@ -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)
});
}
8 changes: 7 additions & 1 deletion src/renderer/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<StrictMode>
<App />
</StrictMode>
Expand Down
4 changes: 4 additions & 0 deletions src/renderer/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
122 changes: 122 additions & 0 deletions tests/provider-secrets-settings.test.mjs
Original file line number Diff line number Diff line change
@@ -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, []);
});
64 changes: 64 additions & 0 deletions tests/uncaught-error-recovery.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading