diff --git a/CHANGELOG.md b/CHANGELOG.md index 21dc024e79..d407dfbc69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ### Diagnostics - Each environment has a Host dashboard (the environment card's menu, Settings > Remote Servers, and the status bar's Resource Manager) with its host's CPU, memory and volumes, live and since the server started. It lists the volumes it hides and why, shows per-volume statistics, never reads network volumes, and says when the volume list is not answering. Agents can read the same figures with the `host_metrics` MCP tool. (#292) +- The Host dashboard on Linux no longer lists another running BiBCode AppImage's mount, or one a killed instance left behind, as a full read-only volume. (#353) +- A refused pairing offer or pairing credential is now logged as a warning with a fixed reason, so a pairing that fails shows in the server log; the log never holds the offered link or the credential. (#358) +- An expired pairing link, pairing offer or session is refused from the moment it expires. Before, one whose expiry fell on a whole second could still be accepted for up to a second, because expiry times were compared as text. The same text form could delete a signed-in device's refresh token up to a second early. The server now writes every stored timestamp at one fixed width, and re-writes the timestamps a client sends or a session import carries in that form before storing them. (#362, #366) +- With the Profiler on, an error that the "Something went wrong" screen catches is recorded as a `client.render-error` event and grouped in one finding. It holds the error's type, React's error number, component names and the route pattern, never the error's message. (#360) ### Pipelines @@ -18,10 +22,16 @@ - The profiler warns about a leftover worktree folder only when it shows worktree evidence, one finding per folder, and the warning ends when the folder is gone. BiBCode records the worktrees it creates, so folders that were never worktrees no longer warn every ten minutes. (#344) - Create worktree starts fresh every time it opens, after a creation or a close: the last Name, Create From, mode, base override, Advanced, Create more and agent pick no longer come back. Input stays while the dialog is open, and Create more still keeps the project, mode, agent and base override for its batch. (#346) - Source Control has a **Review changes** button that starts a code review of your branch and its uncommitted changes in a chat panel beside the thread or in an AI Terminal. A Claude review starts in Claude's plan mode, a Codex review runs in Codex's read-only sandbox, other chat agents ask before edits or are only told not to edit, and Settings → Agents and the button's tooltip say which applies. A review's findings offer no Implement. The review prompt can be set per user in Settings → Agents and per project from the button's menu, which also starts a review once with another agent or with added instructions. Mod+Alt+Shift+R starts a review from the keyboard. On Windows, a terminal review gets the prompt with its line breaks as spaces. (#293) +- Sign-in guidance after a failed fetch or push now appears only when Git's own `fatal:` line says it could not read a username, so a commit message or hook output that contains those words no longer triggers it. (#354) +- A pull request that the forge CLI could not look up is no longer asked for again at once on every status change: a failed lookup waits 5 seconds, and a pull request shown for a branch disappears when the lookups keep failing and the last answer is more than 5 minutes old. A status-neutral local change no longer leaves an outdated answer in place, and a lookup that is running when BiBCode quits ends with it. (#355) +- A worktree recreated at the path of a removed one keeps its own creation record, and a record on a volume that is not mounted is no longer deleted by the profiler's worktree audit. A failed worktree creation that is rolled back is now visible to the profiler. (#356) ### Chats - A new worktree's or project's chat lists only its own provider's models in the composer, before the first message too; to use another provider, add a chat panel from the chat header `+`. A new chat that has not been sent still lists every ready provider. A chat whose provider is disabled or removed now says so above the composer and keeps Send unavailable, and **Add as project** in Files opens the new Main on your Default Agent instead of Codex. (#347) +- Expanding a reasoning row in the chat no longer scrolls the list to its end on Windows. (#351) +- The app no longer falls to the "Something went wrong" screen (React error 185) when a closed branch picker in Git Manager, or a closed model picker, receives a new list, as happened after a branch switch and a fetch in a repository with thousands of branches. (#360) +- The model picker opens with an empty search every time, instead of the text from the last time it was open. (#358) ### Files @@ -31,6 +41,12 @@ - Mermaid diagrams in an editable rendered Markdown file are drawn again, as one block that is not edited in place; the file's source is kept as written. (#342) - The Linux AppImage now carries its own GStreamer plugins for the audio Files previews in WAV, MP3, Ogg, WebM and FLAC files, instead of relying on the system's plugins, which its bundled GStreamer cannot load on newer distributions. M4A is not covered: its AAC decoder is not bundled. - A PDF preview in the Files panel is no longer blocked by the page's content security policy in Chromium-based webviews and browsers: the Windows desktop app, and browser mode in Chrome or Edge. +- A read-only rendered Markdown file (large files, and files the editor cannot change safely) now loads its relative images from the project, as the editable view does. (#358) +- After you allow an update to install, the Files block survives a reload of the window and covers a Files source opened during the install, and the "Installing an update" dialog has **Check again**, which asks the desktop whether the install is still running. (#357) +- While an update you allowed is installing, the remote-update notice's **Manage devices** action is switched off and says it is available when the update finishes, instead of opening Settings underneath the dialog. (#357) +- **Share this host** lists this computer's private addresses (local network, VPN or overlay, and Tailscale) while remote access is still local-only, each marked as enabling remote access; choosing one turns remote access on and makes the code for that address. Before, only "Automatic (LAN)" was offered. (#361) +- On Windows, when BiBCode has no administrator rights and needs its firewall rule, it now asks through the Windows permission prompt when you generate a code, and if you decline it says what is missing and offers **Retry**, instead of failing with a raw error. Without administrator rights BiBCode cannot remove that rule later: turning sharing off returns the server to local-only and may leave the rule in place. A rule that was disabled or changed to block is replaced instead of reused. (#361) +- BiBCode no longer manages Tailscale Serve: the Tailscale HTTPS switch is gone, BiBCode only reads Tailscale addresses, and a Serve address you set up yourself is listed when it answers. (#361) ### Appearance @@ -38,6 +54,7 @@ - Clicking a theme row applies it and, when the theme's base differs from the mode on screen, switches the mode with an Undo that restores the previous mode and theme. (#290) - The left panel draws its repository, environment, primary checkout, worktree and chat rows as five visually distinct levels in every built-in theme, and a theme can style each level. (#290) - On the desktop app, exporting a theme or plan opens the native Save dialog, and closing, quitting, restarting or installing an update with unsaved Files edits asks first with Save all, Discard and Cancel. (#290, #342) +- On the macOS desktop app, a menu, popover, select or combobox opened over a native browser preview is drawn above it, and the preview is hidden only where the popup covers it. (#358) ### Terminals @@ -49,6 +66,11 @@ - Opening, collapsing or dragging a panel no longer resizes its terminal's program to 2 or 5 columns on the way: a terminal takes a new size once its panel holds still for a moment, and a small panel that holds still still sizes it. (#348) - A terminal that was off-screen or hidden while its program kept printing after its rows shrank comes back showing its last lines and footer, instead of a view stuck rows above them until you changed a panel's size; a terminal you had scrolled up to read stays where you left it. (#348) - When two windows, for example on two machines, show the same terminal, the window you last used sizes it and the other one shows the whole terminal scaled down to fit instead of cutting off its bottom rows; a window nobody is clicking or typing in, such as an idle server host window, no longer takes the size back when it regains focus, where its web view reports recent input. A click or **Fit to this window** that takes the size leaves the terminal focused, so the first keys you type reach the program. (#348) +- A terminal repaints and refits after a font loads late, and one created after the server's settings arrived no longer keeps a stale picture until a panel is resized. (#348) +- Every terminal counts an emoji as two columns, as programs such as the Claude CLI assume, so their lines no longer drift. (#353) +- **Fit to this window** now says when the server already holds the size, and a terminal no longer shows a transient size change of a few rows right after you return to it. (#353) +- A new terminal tries the WebGL renderer again after an earlier terminal fell back to the standard one. (#353) +- A client uses the typed-last size hand-over only with a server that says it has it, so a client and a server of different versions keep working. (#358) ## [v0.15.3] - 2026-11-08 diff --git a/apps/desktop/e2e/support/app-lifecycle.test.ts b/apps/desktop/e2e/support/app-lifecycle.test.ts index 605b8791b9..1e4f5726cc 100644 --- a/apps/desktop/e2e/support/app-lifecycle.test.ts +++ b/apps/desktop/e2e/support/app-lifecycle.test.ts @@ -1,10 +1,23 @@ -import { describe, expect, it, vi } from "vite-plus/test"; +// @effect-diagnostics nodeBuiltinImport:off - The hook-order test reads the WDIO configuration as text. +// @effect-diagnostics globalTimers:off - The deadline tests time their steps with fake timers. +// @effect-diagnostics globalDate:off - A deadline test moves the faked clock past its budget. +import * as NodeFS from "node:fs"; + +import { parseSync } from "vite-plus"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { + DESKTOP_UI_RECOVERY_DEADLINE_MS, + DESKTOP_UI_TEST_TIMEOUT_MS, + type RecoveryDeadline, dispatchDesktopUiApplicationExit, isDeadDesktopUiWebviewError, isFinalDesktopUiSpec, + prepareRelaunchedDesktopUiSession, recoverDeadDesktopUiWebview, + startRecoveryDeadline, + tolerateDeadDesktopUiWebview, + withinRecoveryDeadline, } from "./app-lifecycle.ts"; describe("dispatchDesktopUiApplicationExit", () => { @@ -80,7 +93,7 @@ describe("recoverDeadDesktopUiWebview", () => { probe: vi.fn(probe), isRunning: vi.fn(async () => running), stopApplication: vi.fn(async () => undefined), - relaunch: vi.fn(async () => undefined), + relaunch: vi.fn(async (_deadline: RecoveryDeadline) => undefined), }); it("does nothing when the probe answers", async () => { @@ -119,3 +132,317 @@ describe("recoverDeadDesktopUiWebview", () => { expect(dead.relaunch).toHaveBeenCalledOnce(); }); }); + +describe("recovery deadline", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const after = (ms: number, value: T): Promise => + new Promise((resolve) => setTimeout(() => resolve(value), ms)); + const dead = () => Promise.reject(new Error("invalid session id")); + + it("a probe that never answers fails at the deadline and relaunches nothing", async () => { + const relaunch = vi.fn(async (_deadline: RecoveryDeadline) => undefined); + const recovery = recoverDeadDesktopUiWebview({ + probe: () => new Promise(() => undefined), + isRunning: async () => true, + stopApplication: async () => undefined, + relaunch, + }); + const outcome = expect(recovery).rejects.toThrow( + "The webview recovery ran out of its 100000 ms during probe.", + ); + await vi.advanceTimersByTimeAsync(100_000); + await outcome; + expect(relaunch).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("the stop and the relaunch share one deadline", async () => { + let remainingAtRelaunch = -1; + const recovery = recoverDeadDesktopUiWebview({ + probe: dead, + isRunning: async () => true, + stopApplication: () => after(60_000, undefined), + relaunch: (deadline) => { + remainingAtRelaunch = deadline.remainingMs(); + return after(50_000, undefined); + }, + }); + const outcome = expect(recovery).rejects.toThrow( + "The webview recovery ran out of its 100000 ms during relaunch.", + ); + await vi.advanceTimersByTimeAsync(100_000); + await outcome; + expect(remainingAtRelaunch).toBeGreaterThanOrEqual(39_000); + expect(remainingAtRelaunch).toBeLessThanOrEqual(40_000); + // The abandoned relaunch settles at 110 s. + await vi.advanceTimersByTimeAsync(10_000); + expect(vi.getTimerCount()).toBe(0); + }); + + it("a recovery whose steps fit completes", async () => { + const recovery = recoverDeadDesktopUiWebview({ + probe: dead, + isRunning: async () => true, + stopApplication: () => after(10_000, undefined), + relaunch: () => after(30_000, undefined), + }); + await vi.advanceTimersByTimeAsync(40_000); + await expect(recovery).resolves.toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it("an operation that settles first clears its timer", async () => { + await expect( + withinRecoveryDeadline(startRecoveryDeadline(1_000), "step", async () => 7), + ).resolves.toBe(7); + expect(vi.getTimerCount()).toBe(0); + }); + + it("the recovery deadline leaves room in the test allowance", () => { + expect(DESKTOP_UI_RECOVERY_DEADLINE_MS).toBeLessThanOrEqual( + DESKTOP_UI_TEST_TIMEOUT_MS - 20_000, + ); + }); + + it("relaunches again when the webview dies a second time in the same worker", async () => { + const relaunch = vi.fn(async (_deadline: RecoveryDeadline) => undefined); + const deps = { + probe: dead, + isRunning: async () => false, + stopApplication: async () => undefined, + relaunch, + }; + await expect(recoverDeadDesktopUiWebview(deps, startRecoveryDeadline())).resolves.toBe(true); + await expect(recoverDeadDesktopUiWebview(deps, startRecoveryDeadline())).resolves.toBe(true); + expect(relaunch).toHaveBeenCalledTimes(2); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("tolerateDeadDesktopUiWebview", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const live = async () => true; + const deadProbe = () => Promise.reject(new Error("Unsupported result type")); + + it("returns the evidence a live webview gives", async () => { + await expect(tolerateDeadDesktopUiWebview(async () => 7, live)).resolves.toBe(7); + }); + + it("drops any evidence error once a probe finds the webview dead", async () => { + // The WebKitGTK screenshot text after a web process crash (Linux lab, 2026-11-13). + const probe = vi.fn(deadProbe); + await expect( + tolerateDeadDesktopUiWebview( + () => Promise.reject(new Error("There was an error creating the snapshot")), + probe, + ), + ).resolves.toBeUndefined(); + expect(probe).toHaveBeenCalledOnce(); + }); + + it("keeps an evidence error a live webview cannot explain", async () => { + await expect( + tolerateDeadDesktopUiWebview(() => Promise.reject(new Error("boom")), live), + ).rejects.toThrow("boom"); + }); + + it("treats a probe that never answers as a dead webview within its bound", async () => { + const evidence = tolerateDeadDesktopUiWebview( + () => Promise.reject(new Error("There was an error creating the snapshot")), + () => new Promise(() => undefined), + ); + const outcome = expect(evidence).resolves.toBeUndefined(); + await vi.advanceTimersByTimeAsync(5_000); + await outcome; + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("prepareRelaunchedDesktopUiSession", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + const session = (order: string[]) => ({ + reloadSession: vi.fn(async () => void order.push("reload")), + probe: vi.fn(async () => void order.push("probe")), + restoreMocks: vi.fn(async () => void order.push("restore")), + waitForPlugin: vi.fn(async () => void order.push("plugin")), + }); + + it("waits for the new webview to answer before restoring the mocks", async () => { + const order: string[] = []; + const steps = session(order); + let attempts = 0; + steps.probe.mockImplementation(async () => { + order.push("probe"); + attempts += 1; + if (attempts < 3) throw new Error("Unsupported result type"); + }); + const prepared = prepareRelaunchedDesktopUiSession(steps, startRecoveryDeadline()); + await vi.advanceTimersByTimeAsync(1_000); + await expect(prepared).resolves.toBeUndefined(); + expect(order).toEqual(["reload", "probe", "probe", "probe", "restore", "plugin"]); + expect(vi.getTimerCount()).toBe(0); + }); + + it("a webview that never answers fails within its bound and restores nothing", async () => { + const order: string[] = []; + const steps = session(order); + steps.probe.mockImplementation(() => new Promise(() => undefined)); + const prepared = prepareRelaunchedDesktopUiSession(steps, startRecoveryDeadline()); + const outcome = expect(prepared).rejects.toThrow( + "The relaunched webview did not answer a trivial script within 30000 ms", + ); + await vi.advanceTimersByTimeAsync(31_000); + await outcome; + expect(steps.restoreMocks).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("a deadline that has already run out admits no step", async () => { + const steps = session([]); + await expect( + prepareRelaunchedDesktopUiSession(steps, startRecoveryDeadline(0)), + ).rejects.toThrow("The webview recovery ran out of its 0 ms during reloadSession."); + expect(steps.reloadSession).not.toHaveBeenCalled(); + expect(steps.probe).not.toHaveBeenCalled(); + expect(steps.restoreMocks).not.toHaveBeenCalled(); + expect(steps.waitForPlugin).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("a deadline that runs out between two steps starts no later step", async () => { + const steps = session([]); + // The step settles at once, but the clock has passed the deadline by then. + steps.reloadSession.mockImplementation(async () => { + vi.setSystemTime(Date.now() + 2_000); + }); + await expect( + prepareRelaunchedDesktopUiSession(steps, startRecoveryDeadline(1_000)), + ).rejects.toThrow("The webview recovery ran out of its 1000 ms during webview answer."); + expect(steps.reloadSession).toHaveBeenCalledOnce(); + expect(steps.probe).not.toHaveBeenCalled(); + expect(steps.restoreMocks).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("a mock restore that hangs becomes a clear error inside the deadline", async () => { + const steps = session([]); + steps.restoreMocks.mockImplementation(() => new Promise(() => undefined)); + const deadline = startRecoveryDeadline(); + const prepared = prepareRelaunchedDesktopUiSession(steps, deadline); + const outcome = expect(prepared).rejects.toThrow( + "The webview recovery ran out of its 10000 ms during mock restore.", + ); + await vi.advanceTimersByTimeAsync(10_000); + await outcome; + expect(deadline.remainingMs()).toBeGreaterThan(0); + expect(steps.waitForPlugin).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +describe("wdio.conf.ts hooks", () => { + // Read as text: importing the configuration throws without a packaged binary. The installed + // TypeScript 7 has no compiler API, so the oxc parser Vite+ ships reads it. + const text = NodeFS.readFileSync(new URL("../wdio.conf.ts", import.meta.url), "utf8"); + const parsed = parseSync("wdio.conf.ts", text, { lang: "ts" }); + + interface AstNode { + readonly type: string; + readonly start: number; + readonly end: number; + readonly [key: string]: unknown; + } + const isNode = (value: unknown): value is AstNode => + typeof value === "object" && value !== null && typeof (value as AstNode).type === "string"; + const children = (node: AstNode): AstNode[] => + Object.values(node).flatMap((value) => + Array.isArray(value) ? value.filter(isNode) : isNode(value) ? [value] : [], + ); + // Visits every node with its ancestors, nearest last. + const walk = (node: AstNode, visit: (node: AstNode, ancestors: AstNode[]) => void) => { + const go = (current: AstNode, ancestors: AstNode[]): void => { + visit(current, ancestors); + for (const child of children(current)) go(child, [...ancestors, current]); + }; + go(node, []); + }; + const sourceOf = (node: AstNode): string => text.slice(node.start, node.end); + + const hookBody = (name: string): AstNode => { + let body: AstNode | undefined; + walk(parsed.program as unknown as AstNode, (node, ancestors) => { + const declarator = ancestors.at(-2); + if ( + node.type === "Property" && + sourceOf(node.key as AstNode) === name && + isNode(node.value) && + node.value.type === "ArrowFunctionExpression" && + isNode(node.value.body) && + node.value.body.type === "BlockStatement" && + declarator?.type === "VariableDeclarator" && + sourceOf(declarator.id as AstNode) === "config" + ) { + body = node.value.body; + } + }); + if (body === undefined) throw new Error(`The exported config has no ${name} hook.`); + return body; + }; + const callee = (call: AstNode): string => sourceOf(call.callee as AstNode); + + it("parses the configuration", () => { + expect(parsed.errors).toEqual([]); + }); + + it.each(["beforeTest", "before"])( + "the %s hook registers the probe before any browser command", + (name) => { + const first = (hookBody(name).body as AstNode[])[0]; + expect(first?.type).toBe("ExpressionStatement"); + expect(first === undefined ? undefined : sourceOf(first)).toBe( + "await ensureLiveDesktopUiWebview();", + ); + }, + ); + + it("the afterTest hook collects failure evidence through the tolerance", () => { + const evidence: { readonly call: string; readonly tolerated: boolean }[] = []; + walk(hookBody("afterTest"), (node, ancestors) => { + if ( + node.type === "CallExpression" && + ["browser.saveScreenshot", "browser.getPageSource"].includes(callee(node)) + ) { + evidence.push({ + call: callee(node), + tolerated: ancestors.some( + (ancestor) => + ancestor.type === "CallExpression" && + callee(ancestor) === "tolerateDeadDesktopUiWebview", + ), + }); + } + }); + expect(evidence.toSorted((a, b) => a.call.localeCompare(b.call))).toEqual([ + { call: "browser.getPageSource", tolerated: true }, + { call: "browser.saveScreenshot", tolerated: true }, + ]); + }); +}); diff --git a/apps/desktop/e2e/support/app-lifecycle.ts b/apps/desktop/e2e/support/app-lifecycle.ts index 0a69ee070e..27e8d7c317 100644 --- a/apps/desktop/e2e/support/app-lifecycle.ts +++ b/apps/desktop/e2e/support/app-lifecycle.ts @@ -215,6 +215,65 @@ export function desktopUiPlatform(environment: NodeJS.ProcessEnv = process.env): return host === "darwin" ? "mac" : host === "win32" ? "win" : "linux"; } +/** `mochaOpts.timeout` of `wdio.conf.ts`: the allowance of one test and of each of its hooks. */ +export const DESKTOP_UI_TEST_TIMEOUT_MS = 120_000; +/** + * One bound for the probe, the stop, the relaunch, `reloadSession` and the mock restore, leaving + * 20 s of the hook's allowance for the rest of `beforeTest`. A single WDIO command may wait + * `connectionRetryTimeout` (120 s), so every step is raced against this instead. + */ +export const DESKTOP_UI_RECOVERY_DEADLINE_MS = 100_000; + +export interface RecoveryDeadline { + readonly budgetMs: number; + remainingMs(): number; +} + +export function startRecoveryDeadline( + budgetMs: number = DESKTOP_UI_RECOVERY_DEADLINE_MS, +): RecoveryDeadline { + const started = Date.now(); + return { budgetMs, remainingMs: () => Math.max(0, budgetMs - (Date.now() - started)) }; +} + +/** + * Settles with `operation`, or rejects once the deadline runs out first. A late operation is + * abandoned, not cancelled: its own caps (bounded by the deadline) end it. + */ +export function withinRecoveryDeadline( + deadline: RecoveryDeadline, + step: string, + operation: () => Promise, +): Promise { + return new Promise((resolve, reject) => { + // A zero-delay timer loses to the operation's microtasks, so a spent deadline starts nothing. + if (deadline.remainingMs() <= 0) { + reject( + new Error(`The webview recovery ran out of its ${deadline.budgetMs} ms during ${step}.`), + ); + return; + } + const timer = setTimeout( + () => + reject( + new Error(`The webview recovery ran out of its ${deadline.budgetMs} ms during ${step}.`), + ), + deadline.remainingMs(), + ); + // An async wrapper turns a synchronous throw into a rejection, so the timer is still cleared. + (async () => operation())().then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + let relaunched: NodeChildProcess.ChildProcess | null = null; let relaunchCount = 0; @@ -223,7 +282,9 @@ let relaunchCount = 0; * and port, and opens a new WebDriver session on it. The WDIO service only restarts the app * between spec files, so each closing case of one spec gets its own app session here. */ -export async function relaunchDesktopUiApplication(): Promise { +export async function relaunchDesktopUiApplication( + deadline: RecoveryDeadline = startRecoveryDeadline(), +): Promise { const port = webDriverPort(); if (await isDesktopUiApplicationRunning(port)) { throw new Error("The packaged app is still running; it cannot be relaunched."); @@ -234,15 +295,23 @@ export async function relaunchDesktopUiApplication(): Promise { }); // The single-instance plugin hands a new launch to a copy that is still exiting, which then // ends both, so wait until no process runs this packaged binary. - const started = Date.now(); - while (runningBinaryPids(appBinaryPath).length > 0) { - if (Date.now() - started > 30_000) { - throw new Error("The previous packaged app was still exiting after 30 s."); + const exitCapMs = Math.min(30_000, deadline.remainingMs()); + await withinRecoveryDeadline(deadline, "exit wait", async () => { + const started = Date.now(); + while (runningBinaryPids(appBinaryPath).length > 0) { + if (Date.now() - started > exitCapMs) { + throw new Error(`The previous packaged app was still exiting after ${exitCapMs} ms.`); + } + await new Promise((resolve) => setTimeout(resolve, 250)); } - await new Promise((resolve) => setTimeout(resolve, 250)); - } + }); const artifactDirectory = process.env.BIBCODE_E2E_ARTIFACT_DIR; if (!artifactDirectory) throw new Error("BIBCODE_E2E_ARTIFACT_DIR is required."); + if (deadline.remainingMs() <= 0) { + throw new Error( + `The webview recovery ran out of its ${deadline.budgetMs} ms before the relaunch started.`, + ); + } relaunchCount += 1; // A file, not a pipe: an unread pipe would stall the app's debug logging. const log = NodeFS.openSync( @@ -258,33 +327,111 @@ export async function relaunchDesktopUiApplication(): Promise { } finally { NodeFS.closeSync(log); } - const launched = Date.now(); - while (!(await isDesktopUiApplicationRunning(port))) { - if (relaunched.exitCode !== null) { - throw new Error(`The relaunched packaged app exited with ${relaunched.exitCode}.`); + const child = relaunched; + const serveCapMs = Math.min(90_000, deadline.remainingMs()); + await withinRecoveryDeadline(deadline, "serve wait", async () => { + const launched = Date.now(); + while (!(await isDesktopUiApplicationRunning(port))) { + if (child.exitCode !== null) { + throw new Error(`The relaunched packaged app exited with ${child.exitCode}.`); + } + if (Date.now() - launched > serveCapMs) { + throw new Error("The relaunched packaged app never served WebDriver."); + } + await new Promise((resolve) => setTimeout(resolve, 250)); } - if (Date.now() - launched > 90_000) { - throw new Error("The relaunched packaged app never served WebDriver."); + }); + await prepareRelaunchedDesktopUiSession( + { + reloadSession: async () => { + try { + await browser.reloadSession(); + } catch (error) { + // The service copies the old session's mocks onto the new one after it opens; the new + // app never had them, and each spec installs the mocks it needs again. + if (!(error instanceof Error) || !error.message.startsWith("Mock updates failed")) + throw error; + } + }, + probe: () => browser.execute(() => true), + restoreMocks: () => browser.tauri.restoreAllMocks(), + waitForPlugin: () => + browser.waitUntil( + () => + browser.execute(() => { + const host = window as Window & { + readonly wdioTauri?: { readonly execute?: unknown }; + }; + return typeof host.wdioTauri?.execute === "function"; + }), + { + timeout: Math.min(20_000, deadline.remainingMs()), + timeoutMsg: "The relaunched app's Tauri WebDriver plugin did not initialize.", + }, + ), + }, + deadline, + ); +} + +export interface RelaunchedSessionSteps { + readonly reloadSession: () => Promise; + readonly probe: () => Promise; + readonly restoreMocks: () => Promise; + readonly waitForPlugin: () => Promise; +} + +/** Bounds one step by its own cap, never past the shared deadline. */ +function withinStepBound( + deadline: RecoveryDeadline, + capMs: number, + step: string, + operation: () => Promise, +): Promise { + return withinRecoveryDeadline( + startRecoveryDeadline(Math.min(capMs, deadline.remainingMs())), + step, + operation, + ); +} + +/** + * The new session after a relaunch. A fresh WebKitGTK webview can hang the first script it gets, + * so a trivial probe must answer (each try 5 s, all tries 30 s) before any other script; the mock + * restore then has 10 s. Every bound is also cut to the shared deadline. + */ +export async function prepareRelaunchedDesktopUiSession( + steps: RelaunchedSessionSteps, + deadline: RecoveryDeadline, +): Promise { + await withinRecoveryDeadline(deadline, "reloadSession", steps.reloadSession); + const answerCapMs = Math.min(30_000, deadline.remainingMs()); + await withinRecoveryDeadline(deadline, "webview answer", async () => { + const started = Date.now(); + for (;;) { + const leftMs = answerCapMs - (Date.now() - started); + let failure: unknown; + try { + await withinRecoveryDeadline( + startRecoveryDeadline(Math.min(5_000, Math.max(0, leftMs))), + "webview probe", + steps.probe, + ); + return; + } catch (error) { + failure = error; + } + if (Date.now() - started + 250 >= answerCapMs) { + throw new Error( + `The relaunched webview did not answer a trivial script within ${answerCapMs} ms: ${String(failure)}`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 250)); } - await new Promise((resolve) => setTimeout(resolve, 250)); - } - try { - await browser.reloadSession(); - } catch (error) { - // The service copies the old session's mocks onto the new one after it opens; the new app - // never had them, and each spec installs the mocks it needs again. - if (!(error instanceof Error) || !error.message.startsWith("Mock updates failed")) throw error; - } + }); // Drop the old app's mocks, or every later mock call retries them against the new app. - await browser.tauri.restoreAllMocks(); - await browser.waitUntil( - () => - browser.execute(() => { - const host = window as Window & { readonly wdioTauri?: { readonly execute?: unknown } }; - return typeof host.wdioTauri?.execute === "function"; - }), - { timeoutMsg: "The relaunched app's Tauri WebDriver plugin did not initialize." }, - ); + await withinStepBound(deadline, 10_000, "mock restore", steps.restoreMocks); + await withinRecoveryDeadline(deadline, "plugin wait", steps.waitForPlugin); } /** Ends an app this helper relaunched, if it still runs: only a process this helper started. */ @@ -316,33 +463,62 @@ export interface DeadWebviewRecovery { readonly probe: () => Promise; readonly isRunning: () => Promise; readonly stopApplication: () => Promise; - readonly relaunch: () => Promise; + readonly relaunch: (deadline: RecoveryDeadline) => Promise; } /** - * One trivial script before a spec file. When it fails with a dead-session error, the app is + * One trivial script before a test. When it fails with a dead-session error, the app is * relaunched once (after ending a process that survived its webview), so a webview crash fails - * only the spec that hit it. Returns whether it relaunched. + * only the test that hit it. Every step shares one deadline. Returns whether it relaunched. */ -export async function recoverDeadDesktopUiWebview(deps: DeadWebviewRecovery): Promise { +export async function recoverDeadDesktopUiWebview( + deps: DeadWebviewRecovery, + deadline: RecoveryDeadline = startRecoveryDeadline(), +): Promise { try { - await deps.probe(); + await withinRecoveryDeadline(deadline, "probe", deps.probe); return false; } catch (error) { if (!isDeadDesktopUiWebviewError(error)) throw error; } - if (await deps.isRunning()) await deps.stopApplication(); - await deps.relaunch(); + if (await withinRecoveryDeadline(deadline, "liveness check", deps.isRunning)) { + await withinRecoveryDeadline(deadline, "stop", deps.stopApplication); + } + await withinRecoveryDeadline(deadline, "relaunch", () => deps.relaunch(deadline)); return true; } +/** + * Failure evidence from a webview that may have died. When collecting it fails, one trivial + * probe (5 s) decides: a webview that fails or misses the probe is dead, and the evidence is + * dropped whatever the error said; a webview that answers keeps the error, which is then a + * harness fault. Mocha recorded the test's own failure before `afterTest`, so this can only drop + * evidence, never an assertion. Throwing here would skip the worker's remaining tests. + */ +export async function tolerateDeadDesktopUiWebview( + operation: () => Promise, + probe: () => Promise = () => browser.execute(() => true), +): Promise { + try { + return await operation(); + } catch (error) { + if (isDeadDesktopUiWebviewError(error)) return undefined; + try { + await withinRecoveryDeadline(startRecoveryDeadline(5_000), "evidence probe", probe); + } catch { + return undefined; + } + throw error; + } +} + async function stopRunningDesktopUiApplication(): Promise { const pid = desktopUiApplicationPid(); process.kill(pid, "SIGTERM"); await waitForDesktopUiApplicationExit(pid, 10_000).catch(() => process.kill(pid, "SIGKILL")); } -/** The per-spec-file probe of the shared app; see {@link recoverDeadDesktopUiWebview}. */ +/** The per-test probe of the shared app; see {@link recoverDeadDesktopUiWebview}. */ export function ensureLiveDesktopUiWebview(): Promise { return recoverDeadDesktopUiWebview({ probe: () => browser.execute(() => true), diff --git a/apps/desktop/e2e/wdio.conf.ts b/apps/desktop/e2e/wdio.conf.ts index e3cd53dd1d..b804719988 100644 --- a/apps/desktop/e2e/wdio.conf.ts +++ b/apps/desktop/e2e/wdio.conf.ts @@ -9,11 +9,13 @@ import { refreshDesktopUiDocument } from "./support/document-navigation.ts"; import { resolveDesktopAppPath, type DesktopUiPlatform } from "./support/app-path.ts"; import { + DESKTOP_UI_TEST_TIMEOUT_MS, ensureLiveDesktopUiWebview, isDesktopUiApplicationRunning, isFinalDesktopUiSpec, requestDesktopUiApplicationExit, stopRelaunchedDesktopUiApplication, + tolerateDeadDesktopUiWebview, } from "./support/app-lifecycle.ts"; import { deferDesktopUiTestContextCleanupUntilExit, @@ -72,6 +74,8 @@ const desktopUiSpecFiles = ]; // The default specs share one app session. The closing spec ends the app, so it runs last in a // worker of its own, and the service starts the app again for it if a shared spec ended it. +// Because the shared specs run in one worker, `before` runs once for all of them and +// `beforeTest` carries the per-test dead-webview probe. const desktopUiSpecs = requestedSpec ? desktopUiSpecFiles : [desktopUiSpecFiles.filter((spec) => spec !== closingSpec), closingSpec]; @@ -169,10 +173,10 @@ export const config = { reporters: ["spec"], mochaOpts: { ui: "bdd", - timeout: 120_000, + timeout: DESKTOP_UI_TEST_TIMEOUT_MS, }, before: async () => { - // A webview that crashed in an earlier spec would fail this one with the same dead session. + // Worker start only: the plugin check below needs a live webview. `beforeTest` probes per test. await ensureLiveDesktopUiWebview(); const pluginReady = await browser.execute(() => { const host = window as Window & { @@ -191,6 +195,7 @@ export const config = { } }, beforeTest: async () => { + await ensureLiveDesktopUiWebview(); let setupStage = "connection reset and navigation"; try { await resetDesktopUiConnectionCache(); @@ -209,11 +214,18 @@ export const config = { ) => { // A closing case may have ended the app; there is no window left to record. if (!result.passed && (await isDesktopUiApplicationRunning())) { - await browser.saveScreenshot(screenshotPath(`failure-${test.title}`)); - NodeFS.writeFileSync( - screenshotPath(`failure-source-${test.title}`).replace(/\.png$/, ".html"), - await browser.getPageSource(), + // A webview that died in the test cannot answer; a throwing hook would skip the worker's + // remaining tests before their probe could relaunch the app. + await tolerateDeadDesktopUiWebview(() => + browser.saveScreenshot(screenshotPath(`failure-${test.title}`)), ); + const pageSource = await tolerateDeadDesktopUiWebview(() => browser.getPageSource()); + if (pageSource !== undefined) { + NodeFS.writeFileSync( + screenshotPath(`failure-source-${test.title}`).replace(/\.png$/, ".html"), + pageSource, + ); + } } }, after: async (_result: unknown, _capabilities: unknown, currentSpecs: ReadonlyArray) => { diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 27eb3a9cde..97ae1524d7 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -53,8 +53,10 @@ windows-sys = { workspace = true, features = [ "Win32_Security_Cryptography", "Win32_System_Console", "Win32_System_IO", + "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading", + "Win32_UI_Shell", ] } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/apps/desktop/src-tauri/examples/firewall_argv_echo.rs b/apps/desktop/src-tauri/examples/firewall_argv_echo.rs new file mode 100644 index 0000000000..861625d6f4 --- /dev/null +++ b/apps/desktop/src-tauri/examples/firewall_argv_echo.rs @@ -0,0 +1,25 @@ +//! Stand-in for netsh in the Windows argument probe +//! (`firewall::tests::elevation_arguments_reach_a_stand_in_program_byte_for_byte`): +//! writes the arguments it received, as UTF-16 units, to `BIBCODE_ARGV_ECHO_OUT`. + +#[cfg(windows)] +fn main() { + use std::os::windows::ffi::OsStrExt; + + let arguments: Vec> = std::env::args_os() + .skip(1) + .map(|argument| argument.encode_wide().collect()) + .collect(); + let output = std::env::var_os("BIBCODE_ARGV_ECHO_OUT").expect("BIBCODE_ARGV_ECHO_OUT"); + std::fs::write( + output, + serde_json::to_vec(&arguments).expect("arguments serialize"), + ) + .expect("argument echo written"); +} + +#[cfg(not(windows))] +fn main() { + eprintln!("This argument probe stand-in requires Windows."); + std::process::exit(1); +} diff --git a/apps/desktop/src-tauri/src/backend.rs b/apps/desktop/src-tauri/src/backend.rs index 560146b2c9..0589a319e5 100644 --- a/apps/desktop/src-tauri/src/backend.rs +++ b/apps/desktop/src-tauri/src/backend.rs @@ -40,6 +40,8 @@ mod ui_process_observer; const PRIMARY_LOCAL_ENVIRONMENT_ID: &str = "primary"; const DESKTOP_MODE: &str = "desktop"; const BACKEND_BOOTSTRAP_FD: &str = "0"; +/// Sent in the WSL bootstrap only because older `bibcode serve` builds require the field; +/// BiBCode no longer manages Tailscale Serve. const TAILSCALE_SERVE_PORT: u16 = 443; const DESKTOP_LOOPBACK_HOST: &str = "127.0.0.1"; const DESKTOP_LAN_BIND_HOST: &str = "0.0.0.0"; @@ -84,8 +86,6 @@ pub struct BackendRunConfig { pub server_exposure_mode: String, pub endpoint_url: Option, pub advertised_host: Option, - pub tailscale_serve_enabled: bool, - pub tailscale_serve_port: u16, } impl BackendRunConfig { @@ -112,19 +112,16 @@ impl BackendRunConfig { #[derive(Debug, Clone, PartialEq, Eq)] struct BackendDesktopSettings { server_exposure_mode: String, - tailscale_serve_enabled: bool, - tailscale_serve_port: u16, wsl_backend_enabled: bool, wsl_only: bool, wsl_distro: Option, } +/// Keys an earlier version wrote, such as `tailscaleServeEnabled`, are ignored. #[derive(Debug, Default, serde::Deserialize)] #[serde(rename_all = "camelCase")] struct BackendDesktopSettingsDocument { server_exposure_mode: Option, - tailscale_serve_enabled: Option, - tailscale_serve_port: Option, wsl_backend_enabled: Option, wsl_only: Option, wsl_distro: Option, @@ -249,8 +246,6 @@ impl BackendLaunchPlan { server_exposure_mode: "local-only".to_string(), endpoint_url: None, advertised_host: None, - tailscale_serve_enabled: false, - tailscale_serve_port: TAILSCALE_SERVE_PORT, }; let bootstrap = json!({ "mode": DESKTOP_MODE, @@ -3044,8 +3039,6 @@ fn default_launch_plans( server_exposure_mode: exposure.mode, endpoint_url: exposure.endpoint_url, advertised_host: exposure.advertised_host, - tailscale_serve_enabled: settings.tailscale_serve_enabled, - tailscale_serve_port: settings.tailscale_serve_port, }; Ok( BackendLaunchPlan::local(data_root.effective.clone(), config) @@ -3208,13 +3201,6 @@ fn can_listen_on_host(port: u16, host: &str) -> bool { bind_port_probe(port, host).is_some() } -fn normalize_tailscale_serve_port(value: Option) -> u16 { - match value { - Some(value) if (1..=u16::MAX as u64).contains(&value) => value as u16, - _ => TAILSCALE_SERVE_PORT, - } -} - fn normalize_wsl_distro(value: Option) -> Option { value .map(|value| value.trim().to_string()) @@ -3262,8 +3248,6 @@ fn decode_backend_desktop_settings(raw: Option<&str>) -> BackendDesktopSettings Some("network-accessible") => "network-accessible".to_string(), _ => "local-only".to_string(), }, - tailscale_serve_enabled: document.tailscale_serve_enabled.unwrap_or(false), - tailscale_serve_port: normalize_tailscale_serve_port(document.tailscale_serve_port), wsl_backend_enabled: wsl_only || document.wsl_backend_enabled.unwrap_or(false), wsl_only, wsl_distro: normalize_wsl_distro(document.wsl_distro), @@ -4093,8 +4077,6 @@ exit /b 9 server_exposure_mode: "local-only".to_string(), endpoint_url: None, advertised_host: None, - tailscale_serve_enabled: false, - tailscale_serve_port: 443, } } @@ -6017,8 +5999,6 @@ exit /b 9 let resolver = SystemWslCommandResolver; let settings = BackendDesktopSettings { server_exposure_mode: "local-only".to_string(), - tailscale_serve_enabled: false, - tailscale_serve_port: 443, wsl_backend_enabled: true, wsl_only: false, wsl_distro: Some("Debian".to_string()), @@ -6051,8 +6031,6 @@ exit /b 9 ); let settings = BackendDesktopSettings { server_exposure_mode: "local-only".to_string(), - tailscale_serve_enabled: false, - tailscale_serve_port: 443, wsl_backend_enabled: true, wsl_only: true, wsl_distro: None, @@ -6125,8 +6103,6 @@ exit /b 9 let resolver = SystemWslCommandResolver; let settings = BackendDesktopSettings { server_exposure_mode: "local-only".to_string(), - tailscale_serve_enabled: false, - tailscale_serve_port: 443, wsl_backend_enabled: true, wsl_only: true, wsl_distro: Some("Missing".to_string()), @@ -6174,8 +6150,6 @@ exit /b 9 fn wsl_only_planning_failure_never_builds_a_windows_fallback() { let settings = BackendDesktopSettings { server_exposure_mode: "local-only".to_string(), - tailscale_serve_enabled: false, - tailscale_serve_port: 443, wsl_backend_enabled: true, wsl_only: true, wsl_distro: Some("Unavailable".to_string()), @@ -6205,8 +6179,6 @@ exit /b 9 fn persisted_wsl_only_intent_never_plans_windows_when_enabled_flag_is_skewed() { let settings = BackendDesktopSettings { server_exposure_mode: "local-only".to_string(), - tailscale_serve_enabled: false, - tailscale_serve_port: 443, wsl_backend_enabled: false, wsl_only: true, wsl_distro: Some("Ubuntu".to_string()), @@ -6236,8 +6208,6 @@ exit /b 9 fn secondary_wsl_planning_failure_remains_in_desired_topology() { let settings = BackendDesktopSettings { server_exposure_mode: "local-only".to_string(), - tailscale_serve_enabled: false, - tailscale_serve_port: 443, wsl_backend_enabled: true, wsl_only: false, wsl_distro: Some("Ubuntu".to_string()), @@ -6272,8 +6242,6 @@ exit /b 9 fn default_tracking_secondary_identity_is_stable_across_live_and_unavailable_states() { let default_tracking = BackendDesktopSettings { server_exposure_mode: "local-only".to_string(), - tailscale_serve_enabled: false, - tailscale_serve_port: 443, wsl_backend_enabled: true, wsl_only: false, wsl_distro: None, @@ -6335,17 +6303,9 @@ exit /b 9 } #[test] - fn normalizes_tailscale_ports_and_local_only_exposure() { - assert_eq!(normalize_tailscale_serve_port(None), 443); - assert_eq!(normalize_tailscale_serve_port(Some(0)), 443); - assert_eq!(normalize_tailscale_serve_port(Some(1)), 1); - assert_eq!(normalize_tailscale_serve_port(Some(65_535)), 65_535); - assert_eq!(normalize_tailscale_serve_port(Some(65_536)), 443); - + fn normalizes_local_only_exposure() { let settings = BackendDesktopSettings { server_exposure_mode: "unsupported".to_string(), - tailscale_serve_enabled: true, - tailscale_serve_port: 8_443, wsl_backend_enabled: false, wsl_only: false, wsl_distro: None, @@ -6368,8 +6328,6 @@ exit /b 9 defaults, BackendDesktopSettings { server_exposure_mode: "local-only".to_string(), - tailscale_serve_enabled: false, - tailscale_serve_port: 443, wsl_backend_enabled: false, wsl_only: false, wsl_distro: None, @@ -6377,6 +6335,7 @@ exit /b 9 ); assert_eq!(decode_backend_desktop_settings(Some("not-json")), defaults); + // Tailscale Serve keys an earlier version stored are ignored, not an error. assert_eq!( decode_backend_desktop_settings(Some( r#"{ @@ -6390,8 +6349,6 @@ exit /b 9 )), BackendDesktopSettings { server_exposure_mode: "network-accessible".to_string(), - tailscale_serve_enabled: true, - tailscale_serve_port: 8_443, wsl_backend_enabled: true, wsl_only: true, wsl_distro: Some("Ubuntu-24.04".to_string()), @@ -6406,7 +6363,6 @@ exit /b 9 }"#, )); assert_eq!(invalid.server_exposure_mode, "local-only"); - assert_eq!(invalid.tailscale_serve_port, 443); assert_eq!(invalid.wsl_distro, None); } @@ -6414,8 +6370,6 @@ exit /b 9 fn network_exposure_uses_resolved_host_or_falls_back_closed() { let settings = BackendDesktopSettings { server_exposure_mode: "network-accessible".to_string(), - tailscale_serve_enabled: false, - tailscale_serve_port: 443, wsl_backend_enabled: false, wsl_only: false, wsl_distro: None, @@ -6468,8 +6422,6 @@ exit /b 9 fn local_only_exposure_does_not_resolve_lan_route() { let settings = BackendDesktopSettings { server_exposure_mode: "local-only".to_string(), - tailscale_serve_enabled: false, - tailscale_serve_port: 443, wsl_backend_enabled: false, wsl_only: false, wsl_distro: None, diff --git a/apps/desktop/src-tauri/src/bridge.rs b/apps/desktop/src-tauri/src/bridge.rs index 283ce6de4e..76ef1a46f0 100644 --- a/apps/desktop/src-tauri/src/bridge.rs +++ b/apps/desktop/src-tauri/src/bridge.rs @@ -35,7 +35,7 @@ use crate::security::{ unprotect_string as unprotect_catalog_string, }; use crate::server_exposure::{ - BoxFuture, ExposureOperations, ServerExposureCoordinator, apply_exposure, recover_local, + BoxFuture, ExposureOperations, ServerExposureCoordinator, apply_exposure_after, recover_local, }; #[cfg(not(test))] use crate::ssh::default_home_dir; @@ -65,7 +65,8 @@ const AUTH_TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:t const CLIENT_SETTINGS_FILE_NAME: &str = "client-settings.json"; const CONNECTION_CATALOG_FILE_NAME: &str = "connection-catalog.tauri.json"; const DESKTOP_SETTINGS_FILE_NAME: &str = "desktop-settings.json"; -const DEFAULT_TAILSCALE_SERVE_PORT: u16 = 443; +/// Tailscale Serve's default HTTPS port. BiBCode only probes it; it never configures Serve. +const TAILSCALE_SERVE_HTTPS_PORT: u16 = 443; const PRIMARY_LOCAL_ENVIRONMENT_ID: &str = "primary"; const WSL_INSTANCE_ID_PREFIX: &str = "wsl:"; const REMOTE_API_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); @@ -74,19 +75,17 @@ const TAURI_DESKTOP_BRIDGE_VERSION: u16 = 3; #[derive(Debug, Clone, PartialEq, Eq)] struct DesktopSettings { server_exposure_mode: String, - tailscale_serve_enabled: bool, - tailscale_serve_port: u16, wsl_backend_enabled: bool, wsl_distro: Option, wsl_only: bool, } +/// Keys an earlier version wrote, such as `tailscaleServeEnabled`, are ignored on read and +/// dropped on the next write. #[derive(Debug, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct DesktopSettingsDocument { server_exposure_mode: Option, - tailscale_serve_enabled: Option, - tailscale_serve_port: Option, wsl_backend_enabled: Option, wsl_mode: Option, wsl_distro: Option, @@ -260,8 +259,6 @@ fn desktop_settings_path(app: &AppHandle) -> Result DesktopSettings { DesktopSettings { server_exposure_mode: "local-only".to_string(), - tailscale_serve_enabled: false, - tailscale_serve_port: DEFAULT_TAILSCALE_SERVE_PORT, wsl_backend_enabled: false, wsl_distro: None, wsl_only: false, @@ -275,13 +272,6 @@ fn normalize_server_exposure_mode(value: Option<&str>) -> String { } } -fn normalize_tailscale_serve_port(value: Option) -> u16 { - match value { - Some(value) if (1..=u16::MAX as u64).contains(&value) => value as u16, - _ => DEFAULT_TAILSCALE_SERVE_PORT, - } -} - fn is_valid_distro_name(value: &str) -> bool { let mut chars = value.chars(); let Some(first) = chars.next() else { @@ -317,8 +307,6 @@ fn normalize_desktop_settings_document(document: DesktopSettingsDocument) -> Des server_exposure_mode: normalize_server_exposure_mode( document.server_exposure_mode.as_deref(), ), - tailscale_serve_enabled: document.tailscale_serve_enabled.unwrap_or(false), - tailscale_serve_port: normalize_tailscale_serve_port(document.tailscale_serve_port), wsl_backend_enabled, wsl_distro: normalize_wsl_distro(document.wsl_distro), wsl_only, @@ -328,8 +316,6 @@ fn normalize_desktop_settings_document(document: DesktopSettingsDocument) -> Des fn desktop_settings_to_value(settings: &DesktopSettings) -> Value { json!({ "serverExposureMode": &settings.server_exposure_mode, - "tailscaleServeEnabled": settings.tailscale_serve_enabled, - "tailscaleServePort": settings.tailscale_serve_port, "wslBackendEnabled": settings.wsl_backend_enabled, "wslDistro": &settings.wsl_distro, "wslOnly": settings.wsl_only, @@ -462,8 +448,6 @@ fn server_exposure_state(settings: &DesktopSettings, config: Option<&BackendRunC "management": management, "endpointUrl": &config.endpoint_url, "advertisedHost": &config.advertised_host, - "tailscaleServeEnabled": config.tailscale_serve_enabled, - "tailscaleServePort": config.tailscale_serve_port, }); } @@ -473,8 +457,6 @@ fn server_exposure_state(settings: &DesktopSettings, config: Option<&BackendRunC "management": if settings.wsl_only { "external" } else { "native" }, "endpointUrl": null, "advertisedHost": null, - "tailscaleServeEnabled": settings.tailscale_serve_enabled, - "tailscaleServePort": settings.tailscale_serve_port, }) } @@ -664,7 +646,7 @@ fn advertised_endpoints_for_config( address.interface_name ) }; - endpoints.push(advertised_endpoint( + let mut endpoint = advertised_endpoint( format!("desktop-network:{socket}"), classification.label_kind.as_str(), format!("http://{socket}"), @@ -672,7 +654,13 @@ fn advertised_endpoints_for_config( network_status, Some(is_default), &description, - )?); + )?; + if network_status == "unavailable" + && classification.reachability != AdvertisedAddressReachability::Public + { + endpoint["unavailableReason"] = json!("local-only"); + } + endpoints.push(endpoint); } Ok(endpoints) @@ -700,41 +688,36 @@ fn tailscale_endpoints_for_status( } else { "unavailable" }; - endpoints.push(tailscale_advertised_endpoint( + let mut endpoint = tailscale_advertised_endpoint( format!("tailscale-ip:{http_base_url}"), "Tailscale IP", http_base_url, status, "mixed-content-blocked", "Reachable from devices on the same Tailnet.", - )?); + )?; + if status == "unavailable" { + endpoint["unavailableReason"] = json!("local-only"); + } + endpoints.push(endpoint); } - let Some(magic_dns_name) = &status.magic_dns_name else { - return Ok(endpoints); - }; - let http_base_url = - build_tailscale_https_base_url(magic_dns_name, config.tailscale_serve_port)?; - endpoints.push(tailscale_advertised_endpoint( - format!("tailscale-magicdns:{http_base_url}"), - "Tailscale HTTPS", - http_base_url, - if magic_dns_reachable { - "available" - } else { - "unavailable" - }, - if magic_dns_reachable { - "compatible" - } else { - "requires-configuration" - }, - if magic_dns_reachable { - "HTTPS endpoint served by Tailscale Serve." - } else { - "MagicDNS hostname. Configure Tailscale Serve for HTTPS access." - }, - )?); + // BiBCode does not manage Tailscale Serve. A MagicDNS name is offered only when the + // person's own Serve already answers BiBCode's well-known endpoint over HTTPS. + if let Some(magic_dns_name) = &status.magic_dns_name + && magic_dns_reachable + { + let http_base_url = + build_tailscale_https_base_url(magic_dns_name, TAILSCALE_SERVE_HTTPS_PORT)?; + endpoints.push(tailscale_advertised_endpoint( + format!("tailscale-magicdns:{http_base_url}"), + "Tailscale HTTPS", + http_base_url, + "available", + "compatible", + "HTTPS address served by Tailscale Serve, set up outside BiBCode.", + )?); + } Ok(endpoints) } @@ -742,28 +725,40 @@ fn tailscale_endpoints_for_status( async fn tailscale_advertised_endpoints_for_config( config: &BackendRunConfig, ) -> Result, String> { - if config.server_exposure_mode != "network-accessible" && !config.tailscale_serve_enabled { - return Ok(Vec::new()); - } + tailscale_advertised_endpoints_with(config, read_tailscale_status, |base_url| async move { + probe_tailscale_https_endpoint(&base_url).await + }) + .await +} - let status = match read_tailscale_status().await { +/// Reads Tailscale only: `tailscale status --json` and one HTTPS probe of the MagicDNS name. +async fn tailscale_advertised_endpoints_with( + config: &BackendRunConfig, + read_status: F, + probe: P, +) -> Result, String> +where + F: FnOnce() -> Fut, + Fut: std::future::Future>, + P: FnOnce(String) -> ProbeFut, + ProbeFut: std::future::Future, +{ + let status = match read_status().await { Ok(status) => status, Err(error) => { tracing::debug!("Tailscale advertised endpoint discovery skipped: {error}"); return Ok(Vec::new()); } }; - let magic_dns_reachable = if config.tailscale_serve_enabled { - match status.magic_dns_name.as_ref() { - Some(magic_dns_name) => { - let base_url = - build_tailscale_https_base_url(magic_dns_name, config.tailscale_serve_port)?; - probe_tailscale_https_endpoint(&base_url).await - } - None => false, + let magic_dns_reachable = match status.magic_dns_name.as_ref() { + Some(magic_dns_name) => { + probe(build_tailscale_https_base_url( + magic_dns_name, + TAILSCALE_SERVE_HTTPS_PORT, + )?) + .await } - } else { - false + None => false, }; tailscale_endpoints_for_status(config, &status, magic_dns_reachable) @@ -1363,7 +1358,8 @@ impl ExposureOperations for DesktopExposureOperations<'_> { } fn sync_firewall(&self, enabled: bool) -> BoxFuture<'_, Result<(), String>> { - Box::pin(crate::firewall::sync_remote_access_rule(enabled)) + // Inside the exposure lock nothing waits for a person. + Box::pin(crate::firewall::sync_remote_access_rule(enabled, false)) } fn stop_backend(&self) -> BoxFuture<'_, Result<(), String>> { @@ -1419,12 +1415,22 @@ pub async fn desktop_bridge_apply_server_exposure( backend: State<'_, BackendSupervisor>, coordinator: State<'_, ServerExposureCoordinator>, desired: String, + allow_elevation_prompt: Option, ) -> Result { let operations = DesktopExposureOperations { app: &app, backend: backend.inner(), }; - let transition = apply_exposure(coordinator.inner(), &operations, &desired).await?; + // Only a widen the person started in Share this host may raise the Windows prompt. + let prompt_first = async { + if allow_elevation_prompt == Some(true) { + crate::firewall::sync_remote_access_rule(true, true).await + } else { + Ok(()) + } + }; + let transition = + apply_exposure_after(coordinator.inner(), &operations, &desired, prompt_first).await?; let settings = read_desktop_settings(&app)?; Ok(server_exposure_state( &settings, @@ -1432,33 +1438,18 @@ pub async fn desktop_bridge_apply_server_exposure( )) } +/// Removed: BiBCode no longer manages Tailscale Serve. Kept registered so an older web +/// bundle gets the `tauri_capability_unsupported` payload the web bridge already treats as +/// unsupported, instead of an unknown-command error. It changes nothing, neither the desktop +/// settings nor Tailscale. #[tauri::command] -pub async fn desktop_bridge_set_tailscale_serve_enabled( - app: AppHandle, - backend: State<'_, BackendSupervisor>, - coordinator: State<'_, ServerExposureCoordinator>, - input: Value, -) -> Result { - coordinator - .run_exclusive(async { - let enabled = input - .get("enabled") - .and_then(Value::as_bool) - .unwrap_or(false); - let requested_port = input.get("port").and_then(Value::as_u64); - let settings = update_desktop_settings(&app, |settings| { - settings.tailscale_serve_enabled = enabled; - settings.tailscale_serve_port = normalize_tailscale_serve_port( - requested_port.or(Some(settings.tailscale_serve_port as u64)), - ); - })?; - let restarted_config = backend - .restart_default_if_active_preserving_exposure(app.clone()) - .await?; - let current_config = restarted_config.or_else(|| backend.current_run_config()); - Ok(server_exposure_state(&settings, current_config.as_ref())) - }) - .await +pub fn desktop_bridge_set_tailscale_serve_enabled(_input: Value) -> Result { + Err(json!({ + "code": "tauri_capability_unsupported", + "method": "setTailscaleServeEnabled", + "capability": "Tailscale Serve management", + "message": "setTailscaleServeEnabled was removed: BiBCode no longer manages Tailscale Serve.", + })) } #[tauri::command] @@ -2478,8 +2469,6 @@ mod tests { server_exposure_mode: mode.to_owned(), endpoint_url: wide.then(|| "http://192.168.1.25:13773".to_owned()), advertised_host: wide.then(|| "192.168.1.25".to_owned()), - tailscale_serve_enabled: false, - tailscale_serve_port: 443, } } @@ -3055,8 +3044,6 @@ mod tests { server_exposure_mode: "local-only".to_string(), endpoint_url: None, advertised_host: None, - tailscale_serve_enabled: false, - tailscale_serve_port: 443, } } @@ -3399,15 +3386,86 @@ mod tests { let mut config = test_run_config(); config.server_exposure_mode = "local-only".to_string(); - config.tailscale_serve_enabled = false; assert!( - tailscale_advertised_endpoints_for_config(&config) - .await - .expect("disabled Tailscale discovery should succeed") - .is_empty() + tailscale_advertised_endpoints_with( + &config, + || async { Err("tailscale is not installed".to_string()) }, + |_| async { panic!("no probe without Tailscale") } + ) + .await + .expect("missing Tailscale discovery should succeed") + .is_empty() ); } + fn tailscale_status_with_magic_dns() -> TailscaleStatus { + TailscaleStatus { + magic_dns_name: Some("desktop.tail.example.test".to_string()), + tailnet_ipv4_addresses: vec!["100.64.0.7".to_string()], + } + } + + #[tokio::test] + async fn advertised_endpoints_read_tailscale_addresses_while_local_only() { + let mut config = test_run_config(); + config.server_exposure_mode = "local-only".to_string(); + + let endpoints = tailscale_advertised_endpoints_with( + &config, + || async { Ok(tailscale_status_with_magic_dns()) }, + |_| async { false }, + ) + .await + .expect("local-only Tailscale discovery should succeed"); + + // Serve does not answer, so no HTTPS address and no setup row; only the tailnet IP. + assert_eq!(endpoints.len(), 1); + assert_eq!(endpoints[0]["httpBaseUrl"], "http://100.64.0.7:13773/"); + assert_eq!(endpoints[0]["status"], "unavailable"); + assert_eq!(endpoints[0]["unavailableReason"], "local-only"); + } + + #[tokio::test] + async fn advertised_endpoints_list_a_tailscale_serve_address_set_up_outside_bibcode() { + let config = test_run_config(); + let probed = std::sync::Mutex::new(None); + + let endpoints = tailscale_advertised_endpoints_with( + &config, + || async { Ok(tailscale_status_with_magic_dns()) }, + |base_url| { + *probed.lock().unwrap() = Some(base_url); + async { true } + }, + ) + .await + .expect("Tailscale discovery should succeed"); + + assert_eq!( + probed.lock().unwrap().as_deref(), + Some("https://desktop.tail.example.test/") + ); + let https = endpoints + .iter() + .find(|endpoint| { + endpoint["id"] + .as_str() + .unwrap() + .starts_with("tailscale-magicdns:") + }) + .expect("the answering HTTPS address is listed"); + assert_eq!(https["httpBaseUrl"], "https://desktop.tail.example.test/"); + assert_eq!(https["status"], "available"); + } + + #[test] + fn the_removed_tailscale_serve_command_answers_unsupported_and_changes_nothing() { + let error = desktop_bridge_set_tailscale_serve_enabled(json!({"enabled": false})) + .expect_err("Tailscale Serve is no longer managed"); + assert_eq!(error["code"], "tauri_capability_unsupported"); + assert_eq!(error["method"], "setTailscaleServeEnabled"); + } + #[test] fn wsl_state_exposes_a_tagged_primary_failure_without_a_fallback_bootstrap() { let settings = DesktopSettings { @@ -3514,8 +3572,6 @@ mod tests { fn normalizes_desktop_settings_with_legacy_wsl_mode() { let settings = normalize_desktop_settings_document(DesktopSettingsDocument { server_exposure_mode: Some("network-accessible".to_string()), - tailscale_serve_enabled: Some(true), - tailscale_serve_port: Some(8443), wsl_backend_enabled: None, wsl_mode: Some("wsl".to_string()), wsl_distro: Some("Ubuntu-24.04".to_string()), @@ -3526,8 +3582,6 @@ mod tests { settings, DesktopSettings { server_exposure_mode: "network-accessible".to_string(), - tailscale_serve_enabled: true, - tailscale_serve_port: 8443, wsl_backend_enabled: true, wsl_distro: Some("Ubuntu-24.04".to_string()), wsl_only: true, @@ -3561,12 +3615,26 @@ mod tests { assert!(value.get("updateChannelConfiguredByUser").is_none()); } + #[test] + fn a_stored_tailscale_serve_preference_is_ignored_and_dropped_on_write() { + let document: DesktopSettingsDocument = serde_json::from_value(json!({ + "serverExposureMode": "network-accessible", + "tailscaleServeEnabled": true, + "tailscaleServePort": 8443, + })) + .expect("settings an earlier version wrote still load"); + let settings = normalize_desktop_settings_document(document); + assert_eq!(settings.server_exposure_mode, "network-accessible"); + let value = desktop_settings_to_value(&settings); + + assert!(value.get("tailscaleServeEnabled").is_none()); + assert!(value.get("tailscaleServePort").is_none()); + } + #[test] fn invalid_desktop_settings_fall_back_to_safe_defaults() { let settings = normalize_desktop_settings_document(DesktopSettingsDocument { server_exposure_mode: Some("public-internet".to_string()), - tailscale_serve_enabled: Some(true), - tailscale_serve_port: Some(70_000), wsl_backend_enabled: Some(false), wsl_mode: Some("wsl".to_string()), wsl_distro: Some(" Ubuntu ".to_string()), @@ -3577,8 +3645,6 @@ mod tests { settings, DesktopSettings { server_exposure_mode: "local-only".to_string(), - tailscale_serve_enabled: true, - tailscale_serve_port: DEFAULT_TAILSCALE_SERVE_PORT, wsl_backend_enabled: false, wsl_distro: None, wsl_only: false, @@ -3594,28 +3660,17 @@ mod tests { desktop_settings_to_value(&settings), json!({ "serverExposureMode": "local-only", - "tailscaleServeEnabled": false, - "tailscaleServePort": 443, "wslBackendEnabled": false, "wslDistro": null, "wslOnly": false, }) ); - assert_eq!(normalize_tailscale_serve_port(Some(1)), 1); - assert_eq!( - normalize_tailscale_serve_port(Some(u16::MAX as u64)), - u16::MAX - ); - assert_eq!(normalize_tailscale_serve_port(Some(0)), 443); - assert_eq!(normalize_tailscale_serve_port(None), 443); assert_eq!(normalize_server_exposure_mode(None), "local-only"); } #[test] fn server_exposure_state_prefers_runtime_state_when_available() { let mut settings = default_desktop_settings(); - settings.tailscale_serve_enabled = true; - settings.tailscale_serve_port = 8443; assert_eq!( server_exposure_state(&settings, None), @@ -3625,8 +3680,6 @@ mod tests { "management": "native", "endpointUrl": null, "advertisedHost": null, - "tailscaleServeEnabled": true, - "tailscaleServePort": 8443, }) ); @@ -3642,8 +3695,6 @@ mod tests { "management": "native", "endpointUrl": "http://192.168.1.20:13773", "advertisedHost": "192.168.1.20", - "tailscaleServeEnabled": false, - "tailscaleServePort": 443, }) ); @@ -3661,8 +3712,6 @@ mod tests { "management": "external", "endpointUrl": null, "advertisedHost": null, - "tailscaleServeEnabled": false, - "tailscaleServePort": 443, }) ); } @@ -3793,6 +3842,54 @@ mod tests { assert_eq!(endpoints[1]["isDefault"], false); } + #[test] + fn advertised_endpoints_say_local_only_is_why_private_addresses_are_unavailable() { + let config = test_run_config(); + let addresses = [ + NetworkAddress { + interface_name: "Ethernet".to_string(), + ip: "192.168.0.10".parse().expect("LAN fixture"), + is_default_route: true, + }, + NetworkAddress { + interface_name: "Overlay".to_string(), + ip: "10.20.0.5".parse().expect("overlay fixture"), + is_default_route: false, + }, + NetworkAddress { + interface_name: "Tailnet".to_string(), + ip: "100.64.0.7".parse().expect("CGNAT fixture"), + is_default_route: false, + }, + NetworkAddress { + interface_name: "Internet".to_string(), + ip: "8.8.8.8".parse().expect("public fixture"), + is_default_route: false, + }, + ]; + + let local = advertised_endpoints_for_config(&config, &addresses) + .expect("local-only endpoints should build"); + assert_eq!(local.len(), 5); + assert!(local[0].get("unavailableReason").is_none()); + for endpoint in &local[1..4] { + assert_eq!(endpoint["status"], "unavailable"); + assert_eq!(endpoint["unavailableReason"], "local-only"); + } + // Native sharing never uses a public address, so widening is not why it is unavailable. + assert_eq!(local[4]["reachability"], "public"); + assert!(local[4].get("unavailableReason").is_none()); + + let mut wide_config = config; + wide_config.server_exposure_mode = "network-accessible".to_string(); + let wide = advertised_endpoints_for_config(&wide_config, &addresses) + .expect("network-accessible endpoints should build"); + assert!( + wide.iter() + .all(|endpoint| endpoint.get("unavailableReason").is_none()) + ); + } + #[test] fn local_only_discovery_marks_private_default_candidate_unavailable() { let config = test_run_config(); @@ -4080,8 +4177,6 @@ mod tests { server_exposure_mode: "network-accessible".to_string(), endpoint_url: Some("http://192.168.1.20:13773".to_string()), advertised_host: Some("192.168.1.20".to_string()), - tailscale_serve_enabled: true, - tailscale_serve_port: 8443, }; let status = TailscaleStatus { magic_dns_name: Some("desktop.tail.ts.net".to_string()), @@ -4114,14 +4209,11 @@ mod tests { assert_eq!( endpoints[1]["id"], - "tailscale-magicdns:https://desktop.tail.ts.net:8443/" + "tailscale-magicdns:https://desktop.tail.ts.net/" ); assert_eq!(endpoints[1]["label"], "Tailscale HTTPS"); - assert_eq!( - endpoints[1]["httpBaseUrl"], - "https://desktop.tail.ts.net:8443/" - ); - assert_eq!(endpoints[1]["wsBaseUrl"], "wss://desktop.tail.ts.net:8443/"); + assert_eq!(endpoints[1]["httpBaseUrl"], "https://desktop.tail.ts.net/"); + assert_eq!(endpoints[1]["wsBaseUrl"], "wss://desktop.tail.ts.net/"); assert_eq!(endpoints[1]["status"], "available"); assert_eq!( endpoints[1]["compatibility"]["hostedHttpsApp"], @@ -4142,8 +4234,6 @@ mod tests { server_exposure_mode: "local-only".to_string(), endpoint_url: None, advertised_host: None, - tailscale_serve_enabled: false, - tailscale_serve_port: 8443, }; let status = TailscaleStatus { magic_dns_name: None, @@ -4161,7 +4251,7 @@ mod tests { } #[test] - fn marks_unprobed_tailscale_magic_dns_as_requires_configuration() { + fn an_unanswered_magic_dns_name_is_not_offered_and_no_setup_is_suggested() { let config = BackendRunConfig { environment_id: "primary".to_string(), label: "Local".to_string(), @@ -4173,8 +4263,6 @@ mod tests { server_exposure_mode: "local-only".to_string(), endpoint_url: None, advertised_host: None, - tailscale_serve_enabled: false, - tailscale_serve_port: 443, }; let status = TailscaleStatus { magic_dns_name: Some("desktop.tail.ts.net".to_string()), @@ -4184,13 +4272,7 @@ mod tests { let endpoints = tailscale_endpoints_for_status(&config, &status, false).expect("endpoint should build"); - assert_eq!(endpoints.len(), 1); - assert_eq!(endpoints[0]["httpBaseUrl"], "https://desktop.tail.ts.net/"); - assert_eq!(endpoints[0]["status"], "unavailable"); - assert_eq!( - endpoints[0]["compatibility"]["hostedHttpsApp"], - "requires-configuration" - ); + assert!(endpoints.is_empty(), "{endpoints:?}"); } #[test] @@ -4689,13 +4771,13 @@ mod tests { // Valid exposure transitions are covered with fake operations in the // server_exposure tests. This runtime-agnostic IPC smoke must not // mutate the Windows host firewall. - assert!( - invoke( - "desktop_bridge_set_tailscale_serve_enabled", - json!({"input":{"enabled":false,"port":443}}), - ) - .is_ok() - ); + // Removed: an older web bundle gets the structured unsupported answer. + let removed = invoke( + "desktop_bridge_set_tailscale_serve_enabled", + json!({"input":{"enabled":false,"port":443}}), + ) + .expect_err("Tailscale Serve is no longer managed"); + assert_eq!(removed["code"], "tauri_capability_unsupported"); assert!( invoke( "desktop_bridge_set_wsl_backend_enabled", diff --git a/apps/desktop/src-tauri/src/data_safety.rs b/apps/desktop/src-tauri/src/data_safety.rs index 3500b4868e..0dc6975712 100644 --- a/apps/desktop/src-tauri/src/data_safety.rs +++ b/apps/desktop/src-tauri/src/data_safety.rs @@ -742,8 +742,6 @@ mod tests { server_exposure_mode: "local-only".to_owned(), endpoint_url: None, advertised_host: None, - tailscale_serve_enabled: false, - tailscale_serve_port: 443, } } diff --git a/apps/desktop/src-tauri/src/firewall.rs b/apps/desktop/src-tauri/src/firewall.rs index 4f31ce53f8..bde20e5eba 100644 --- a/apps/desktop/src-tauri/src/firewall.rs +++ b/apps/desktop/src-tauri/src/firewall.rs @@ -4,6 +4,8 @@ //! program-scoped rather than port-scoped. Non-Windows platforms have no managed //! firewall here and every call is a successful no-op. +#[cfg(windows)] +use bibcode_server::diagnostics::profiler::exits::{self, Exited, Watch}; #[cfg(windows)] use bibcode_server::process::ProcessRunner; #[cfg(any(windows, test))] @@ -71,6 +73,7 @@ pub(crate) fn remote_access_rule_show_args() -> Vec { "show".to_owned(), "rule".to_owned(), format!("name={REMOTE_ACCESS_RULE_NAME}"), + "verbose".to_owned(), ] } @@ -106,6 +109,154 @@ pub(crate) fn remote_access_store_probe_args() -> Vec { ] } +/// Reads the stored form of the rule. Its fields are fixed tokens (`Active=TRUE`), unlike +/// the localized netsh listing, and standard users can read the key. +#[must_use] +#[cfg_attr( + all(not(windows), not(test)), + expect(dead_code, reason = "Windows-only firewall command") +)] +pub(crate) fn remote_access_rule_store_args() -> Vec { + vec![ + "query".to_owned(), + r"HKLM\SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy\FirewallRules" + .to_owned(), + "/f".to_owned(), + format!("Name={REMOTE_ACCESS_RULE_NAME}|"), + "/d".to_owned(), + ] +} + +/// The reason token narrowing reports when Windows refused to delete the rule for lack of +/// administrator permission. Recovery tolerates it: the listener is verified loopback-only +/// on its own, and the rule is scoped to this program. +pub(crate) const FIREWALL_RULE_LEFT_TOKEN: &str = "firewall-rule-left"; + +/// Why a firewall change failed. Only the token leaves this module: netsh output is +/// localized and can name paths. +#[cfg(any(windows, test))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum FirewallFailure { + /// Adding or replacing the rule needs administrator permission this process lacks, + /// or the person declined, dismissed or let the Windows prompt time out. + ElevationRequired, + /// Narrowing could not delete the rule for lack of administrator permission. + RuleLeft, + ReadFailed, + RemoveFailed, + AddFailed, + TimedOut, + Unavailable, + /// An earlier Windows prompt is still on screen; no second one is raised. + PromptOpen, + /// The person approved removing a rule for another program path but not adding the + /// new one: the old rule is gone and none was added. + RuleRemovedNotAdded, +} + +#[cfg(any(windows, test))] +impl FirewallFailure { + pub(crate) const fn token(self) -> &'static str { + match self { + Self::PromptOpen => "firewall-prompt-open", + Self::RuleRemovedNotAdded => "firewall-rule-removed-not-added", + Self::ElevationRequired => "firewall-elevation-required", + Self::RuleLeft => FIREWALL_RULE_LEFT_TOKEN, + Self::ReadFailed => "firewall-read-failed", + Self::RemoveFailed => "firewall-remove-failed", + Self::AddFailed => "firewall-add-failed", + Self::TimedOut => "firewall-timed-out", + Self::Unavailable => "firewall-unavailable", + } + } +} + +/// How long the person has to answer the Windows elevation prompt. The 5-second caller +/// deadline still covers every non-interactive command around it. +#[cfg(any(windows, test))] +const ELEVATION_PROMPT_TIMEOUT: Duration = Duration::from_secs(120); + +/// Mandatory-label SIDs in `whoami /groups`. SIDs are not localized, unlike netsh text. +#[cfg(any(windows, test))] +const UNELEVATED_INTEGRITY_SIDS: [&str; 3] = ["S-1-16-4096", "S-1-16-8192", "S-1-16-8448"]; +#[cfg(any(windows, test))] +const ELEVATED_INTEGRITY_SIDS: [&str; 2] = ["S-1-16-12288", "S-1-16-16384"]; + +/// The only programs this module starts. Each runs from the system directory Windows +/// reports, never from `PATH` or an environment variable. +#[cfg(any(windows, test))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FirewallProgram { + Netsh, + Whoami, + Reg, +} + +#[cfg(any(windows, test))] +impl FirewallProgram { + const fn name(self) -> &'static str { + match self { + Self::Netsh => "netsh", + Self::Whoami => "whoami", + Self::Reg => "reg", + } + } +} + +#[cfg(any(windows, test))] +fn system_program_path( + system_directory: &std::path::Path, + program: FirewallProgram, +) -> std::path::PathBuf { + system_directory.join(format!("{}.exe", program.name())) +} + +/// The system directory from `GetSystemDirectoryW`. +#[cfg(windows)] +fn system_directory() -> Result { + use std::os::windows::ffi::OsStringExt; + use windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW; + + let mut buffer = vec![0u16; 1024]; + // SAFETY: the buffer holds `buffer.len()` UTF-16 units. + let length = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) } as usize; + if length == 0 || length >= buffer.len() { + return Err("could not resolve the Windows system directory".to_owned()); + } + buffer.truncate(length); + Ok(std::path::PathBuf::from(std::ffi::OsString::from_wide( + &buffer, + ))) +} + +/// Joins arguments by the Windows command-line rules (`CommandLineToArgvW`) that netsh +/// parses: an argument with a space or tab, or an empty one, is wrapped in double quotes, +/// with the backslashes before the closing quote doubled. Nothing else is special, so +/// quotes of any other kind, `$`, `` ` ``, `&`, `%` and non-ASCII letters pass through +/// as data. A double quote or NUL cannot occur in a Windows path and is refused. +#[cfg(any(windows, test))] +fn windows_command_line(args: &[String]) -> Result { + let mut line = String::new(); + for arg in args { + if arg.contains(['"', '\0']) { + return Err(FirewallFailure::Unavailable); + } + if !line.is_empty() { + line.push(' '); + } + if !arg.is_empty() && !arg.contains([' ', '\t']) { + line.push_str(arg); + continue; + } + let trailing = arg.len() - arg.trim_end_matches('\\').len(); + line.push('"'); + line.push_str(arg); + line.push_str(&"\\".repeat(trailing)); + line.push('"'); + } + Ok(line) +} + #[cfg(any(windows, test))] #[derive(Clone, Debug, Eq, PartialEq)] struct FirewallCommandOutput { @@ -114,13 +265,55 @@ struct FirewallCommandOutput { stderr: String, } +/// How an elevated netsh run ended, as far as this process can tell. Whether the rule +/// exists afterwards is read separately and decides success. +#[cfg(any(windows, test))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ElevatedRun { + /// Approved; netsh ran and exited with this code. + Exited(u32), + /// Declined, dismissed, timed out, or Windows could not show the prompt. + Refused, +} + #[cfg(any(windows, test))] trait FirewallCommandRunner: Sync { fn run( &self, - executable: String, + program: FirewallProgram, args: Vec, ) -> impl std::future::Future> + Send; + + /// Runs the system netsh with `args` elevated, behind one Windows prompt. `prompt` must + /// live until the prompt is really gone, which can be after this future returns. + fn run_elevated( + &self, + netsh_args: Vec, + prompt: PromptGuard, + ) -> impl std::future::Future + Send; +} + +/// Marks a Windows prompt as open until dropped, so no second one is raised meanwhile. +#[cfg(any(windows, test))] +struct PromptGuard(Arc>); + +#[cfg(any(windows, test))] +impl PromptGuard { + fn open(state: &Arc>) -> Result { + let mut locked = state.lock().expect("firewall worker state"); + if locked.prompt_open { + return Err(FirewallFailure::PromptOpen); + } + locked.prompt_open = true; + Ok(Self(Arc::clone(state))) + } +} + +#[cfg(any(windows, test))] +impl Drop for PromptGuard { + fn drop(&mut self) { + self.0.lock().expect("firewall worker state").prompt_open = false; + } } #[cfg(windows)] @@ -129,15 +322,22 @@ struct ProcessFirewallCommandRunner; #[cfg(any(windows, test))] struct FirewallJob { enabled: bool, + allow_prompt: bool, program: Option, deadline: tokio::time::Instant, - completion: oneshot::Sender>, + completion: oneshot::Sender>, } #[cfg(any(windows, test))] #[derive(Default)] struct FirewallWorkerState { pending: Option, + prompt_open: bool, + /// How the rule this process added or accepted read back. A rule is ours when its listing + /// names this program or reads exactly as observed then, even if the listing does not + /// echo a non-ASCII path byte for byte; a rule replaced for another program reads + /// differently and is not ours. Both checks of a widen use this one rule. + own_rule: Option, } #[cfg(any(windows, test))] @@ -159,18 +359,28 @@ impl FirewallWorker { Self { state, wake } } + /// `allow_prompt` lets an enable that Windows refuses for lack of administrator + /// permission ask through the elevation prompt; the caller then also waits for it. async fn sync( &self, enabled: bool, + allow_prompt: bool, program: Option, caller_timeout: Duration, - ) -> Result<(), String> { + ) -> Result<(), FirewallFailure> { let deadline = tokio::time::Instant::now() + caller_timeout; + let wait_until = if allow_prompt { + // Up to two prompts when a rule for another path is replaced. + deadline + 2 * (ELEVATION_PROMPT_TIMEOUT + caller_timeout) + } else { + deadline + }; let (completion, receiver) = oneshot::channel(); let (superseded, worker_unavailable) = { let mut state = self.state.lock().expect("firewall worker state"); let superseded = state.pending.replace(FirewallJob { enabled, + allow_prompt, program, deadline, completion, @@ -185,22 +395,16 @@ impl FirewallWorker { (superseded, worker_unavailable) }; if let Some(job) = superseded { - let _ = job.completion.send(Err( - "firewall worker is saturated; operation was superseded by a newer desired state before execution" - .to_owned(), - )); + let _ = job.completion.send(Err(FirewallFailure::Unavailable)); } if worker_unavailable { - return Err("firewall worker is unavailable".to_owned()); + return Err(FirewallFailure::Unavailable); } - match tokio::time::timeout_at(deadline, receiver).await { + match tokio::time::timeout_at(wait_until, receiver).await { Ok(Ok(result)) => result, - Ok(Err(_)) => Err("firewall worker stopped before reporting completion".to_owned()), - Err(_) => Err(format!( - "firewall operation timed out after {} seconds; fail-closed cleanup is queued", - caller_timeout.as_secs() - )), + Ok(Err(_)) => Err(FirewallFailure::Unavailable), + Err(_) => Err(FirewallFailure::TimedOut), } } } @@ -219,43 +423,64 @@ async fn run_firewall_worker( let Some(job) = job else { break; }; - let already_late = tokio::time::Instant::now() >= job.deadline; + let mut deadline = job.deadline; + let already_late = tokio::time::Instant::now() >= deadline; + let own_rule = state.lock().expect("firewall worker state").own_rule; + let enabled = job.enabled && !already_late; let mut result = if already_late { - sync_remote_access_rule_with_runner(false, &runner, || { + sync_rule(false, &runner, own_rule, || { Err("expired firewall job must not add a rule".to_owned()) }) .await } else { - let program = job.program; - sync_remote_access_rule_with_runner(job.enabled, &runner, move || { + let program = job.program.clone(); + sync_rule(job.enabled, &runner, own_rule, move || { program.ok_or_else(|| "desktop executable was not provided".to_owned()) }) .await }; - let completed_late = already_late || tokio::time::Instant::now() >= job.deadline; + if job.enabled + && job.allow_prompt + && result == Err(FirewallFailure::ElevationRequired) + && tokio::time::Instant::now() < deadline + && let Some(program) = job.program.as_deref() + { + // The wait for a person gets its own budget; the non-interactive steps + // before it were held to the caller deadline. + deadline = tokio::time::Instant::now() + + 2 * (ELEVATION_PROMPT_TIMEOUT + FIREWALL_CALLER_TIMEOUT); + result = elevate_remote_access_rule(&runner, &state, program).await; + } + if let Ok(observed) = result { + // An enable leaves this program's rule in place; a disable leaves none. + state.lock().expect("firewall worker state").own_rule = + if enabled { observed } else { None }; + } + let mut result = result.map(|_| ()); + + let completed_late = already_late || tokio::time::Instant::now() >= deadline; if job.enabled && !already_late && completed_late { - let cleanup = sync_remote_access_rule_with_runner(false, &runner, || { + let own_rule = state.lock().expect("firewall worker state").own_rule; + let cleanup = sync_rule(false, &runner, own_rule, || { Err("firewall cleanup must not add a rule".to_owned()) }) .await; - result = match (result, cleanup) { - (_, Ok(())) => Err( - "firewall enable completed after its caller deadline; the late rule was removed" - .to_owned(), - ), - (Ok(()), Err(cleanup_error)) => Err(format!( - "firewall enable completed after its caller deadline, and cleanup failed: {cleanup_error}" - )), - (Err(operation_error), Err(cleanup_error)) => Err(format!( - "late firewall operation failed: {operation_error}; cleanup also failed: {cleanup_error}" - )), - }; + if cleanup.is_ok() { + state.lock().expect("firewall worker state").own_rule = None; + } + if let Err(cleanup) = cleanup { + tracing::warn!( + reason = cleanup.token(), + "late remote-access firewall enable could not be undone" + ); + } + result = Err(FirewallFailure::TimedOut); } else if completed_late && result.is_ok() { - result = Err("firewall cleanup completed after its caller deadline".to_owned()); + result = Err(FirewallFailure::TimedOut); } - if job.completion.send(result.clone()).is_err() { + if job.completion.send(result).is_err() { tracing::warn!( result = ?result, "firewall worker completed an operation after its caller stopped waiting" @@ -273,23 +498,144 @@ fn firewall_process_input(executable: String, args: Vec) -> ProcessRunIn .with_output_mode(OutputMode::Truncate) } +/// How a `ShellExecuteExW` run ended. +#[cfg(windows)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ShellRun { + Exited(u32), + /// The person declined or dismissed the prompt (`ERROR_CANCELLED`). + Cancelled, + TimedOut, +} + +/// Starts `file` with `parameters` through the shell and waits for it. With the `runas` +/// verb Windows shows its elevation prompt and this call blocks until the person answers. +/// The waiting thread owns and closes every handle; a caller that stops waiting leaves +/// it to finish on its own, because Windows offers no way to withdraw a shown prompt. +#[cfg(windows)] +fn shell_execute_and_wait( + verb: &str, + file: &std::path::Path, + parameters: &str, + timeout: Duration, +) -> Result { + use std::os::windows::ffi::OsStrExt; + use std::os::windows::process::ExitStatusExt; + use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_CANCELLED, GetLastError, WAIT_OBJECT_0, + }; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, GetProcessId, WaitForSingleObject, + }; + use windows_sys::Win32::UI::Shell::{ + SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW, + }; + + let wide = |value: &std::ffi::OsStr| value.encode_wide().chain([0]).collect::>(); + let verb = wide(std::ffi::OsStr::new(verb)); + let file_wide = wide(file.as_os_str()); + let parameters = wide(std::ffi::OsStr::new(parameters)); + let started = std::time::Instant::now(); + // SAFETY: an all-zero SHELLEXECUTEINFOW is valid; the strings outlive the call. + let mut info: SHELLEXECUTEINFOW = unsafe { std::mem::zeroed() }; + info.cbSize = std::mem::size_of::() as u32; + info.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC; + info.lpVerb = verb.as_ptr(); + info.lpFile = file_wide.as_ptr(); + info.lpParameters = parameters.as_ptr(); + info.nShow = 0; // SW_HIDE: netsh needs no console window. + // SAFETY: `info` is fully initialized for ShellExecuteExW. + if unsafe { ShellExecuteExW(&mut info) } == 0 { + // SAFETY: reads this thread's last error right after the failed call. + let error = unsafe { GetLastError() }; + if error == ERROR_CANCELLED { + return Ok(ShellRun::Cancelled); + } + return Err(format!("ShellExecuteExW failed with error {error}")); + } + let process = info.hProcess; + if process.is_null() { + return Err("ShellExecuteExW returned no process".to_owned()); + } + let remaining = timeout.saturating_sub(started.elapsed()); + let wait_ms = u32::try_from(remaining.as_millis()).unwrap_or(u32::MAX - 1); + // SAFETY: `process` is the live handle ShellExecuteExW returned; closed below. + let (pid, code) = unsafe { + let pid = GetProcessId(process); + let code = if WaitForSingleObject(process, wait_ms) == WAIT_OBJECT_0 { + let mut code = 0u32; + (GetExitCodeProcess(process, &mut code) != 0).then_some(code) + } else { + None + }; + CloseHandle(process); + (pid, code) + }; + let Some(code) = code else { + return Ok(ShellRun::TimedOut); + }; + let exe = file.to_string_lossy(); + exits::report_exit( + Watch::Signals, + &Exited { + kind: "command", + label: "netsh", + exe: &exe, + pid, + started, + }, + &std::process::ExitStatus::from_raw(code), + false, + ); + Ok(ShellRun::Exited(code)) +} + #[cfg(windows)] impl FirewallCommandRunner for ProcessFirewallCommandRunner { async fn run( &self, - executable: String, + program: FirewallProgram, args: Vec, ) -> Result { + let executable = system_program_path(&system_directory()?, program) + .to_string_lossy() + .into_owned(); let output = ProcessRunner - .run(firewall_process_input(executable.clone(), args)) + .run(firewall_process_input(executable, args)) .await - .map_err(|error| format!("failed to run {executable}: {error}"))?; + .map_err(|error| format!("failed to run {}: {error}", program.name()))?; Ok(FirewallCommandOutput { success: output.code == Some(0), stdout: output.stdout, stderr: output.stderr, }) } + + async fn run_elevated(&self, netsh_args: Vec, prompt: PromptGuard) -> ElevatedRun { + let Ok(parameters) = windows_command_line(&netsh_args) else { + return ElevatedRun::Refused; + }; + let Ok(system) = system_directory() else { + return ElevatedRun::Refused; + }; + let netsh = system_program_path(&system, FirewallProgram::Netsh); + let waiting = tokio::task::spawn_blocking(move || { + // Held until ShellExecuteExW returns, after the caller may have stopped waiting. + let _prompt = prompt; + shell_execute_and_wait("runas", &netsh, ¶meters, ELEVATION_PROMPT_TIMEOUT) + }); + match tokio::time::timeout(ELEVATION_PROMPT_TIMEOUT + FIREWALL_CALLER_TIMEOUT, waiting) + .await + { + Ok(Ok(Ok(ShellRun::Exited(code)))) => ElevatedRun::Exited(code), + Ok(Ok(Ok(ShellRun::Cancelled | ShellRun::TimedOut))) => ElevatedRun::Refused, + Ok(Ok(Err(error))) => { + tracing::warn!(%error, "the elevated firewall change could not start"); + ElevatedRun::Refused + } + Ok(Err(_)) | Err(_) => ElevatedRun::Refused, + } + } } #[cfg(any(windows, test))] @@ -301,21 +647,13 @@ fn firewall_command_text(output: &FirewallCommandOutput) -> String { /// reports absence nor succeeds is an error, so a missing rule is accepted /// only after the store was actually read. #[cfg(any(windows, test))] -fn rule_listing_is_absent(output: &FirewallCommandOutput) -> Result { +fn rule_listing_is_absent(output: &FirewallCommandOutput) -> Result { let text = firewall_command_text(output); if text.contains(FIREWALL_RULE_ABSENT_MARKER) { return Ok(true); } if !output.success { - let details = text.trim(); - let details = if details.is_empty() { - "command exited unsuccessfully without output" - } else { - details - }; - return Err(format!( - "failed to delete and verify the remote access firewall rule: {details}" - )); + return Err(FirewallFailure::ReadFailed); } Ok(!text.contains(REMOTE_ACCESS_RULE_NAME)) } @@ -337,7 +675,7 @@ fn listing_needs_store_probe(output: &FirewallCommandOutput) -> bool { async fn confirm_rule_listing_is_absent( runner: &Runner, output: &FirewallCommandOutput, -) -> Result +) -> Result where Runner: FirewallCommandRunner, { @@ -345,8 +683,9 @@ where return rule_listing_is_absent(output); } let probe = runner - .run("netsh".to_owned(), remote_access_store_probe_args()) - .await?; + .run(FirewallProgram::Netsh, remote_access_store_probe_args()) + .await + .map_err(|_| FirewallFailure::ReadFailed)?; if probe.success { return Ok(true); } @@ -354,94 +693,287 @@ where } #[cfg(any(windows, test))] -async fn delete_and_verify_remote_access_rule(runner: &Runner) -> Result<(), String> +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RuleListing { + Absent, + /// Present; `for_program` when the verbose listing names this program's path. + Present { + for_program: bool, + /// A hash of the listing, so a later read can tell whether it is the same rule. + observed: u64, + }, +} + +#[cfg(any(windows, test))] +async fn read_remote_access_rule( + runner: &Runner, + program: Option<&str>, +) -> Result where Runner: FirewallCommandRunner, { let listing = runner - .run("netsh".to_owned(), remote_access_rule_show_args()) - .await?; + .run(FirewallProgram::Netsh, remote_access_rule_show_args()) + .await + .map_err(|_| FirewallFailure::ReadFailed)?; if confirm_rule_listing_is_absent(runner, &listing).await? { - return Ok(()); - } - let deletion = runner - .run("netsh".to_owned(), remote_access_rule_delete_args()) - .await?; - require_firewall_command_success("delete the remote access firewall rule", deletion)?; - let confirmed = runner - .run("netsh".to_owned(), remote_access_rule_show_args()) - .await?; - if confirm_rule_listing_is_absent(runner, &confirmed).await? { - Ok(()) - } else { - Err( - "failed to delete and verify the remote access firewall rule: remote access firewall rule is still present after deletion" - .to_owned(), - ) + return Ok(RuleListing::Absent); } + // The path is compared as text; only its field label is localized. + let text = firewall_command_text(&listing).to_lowercase(); + let observed = { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() + }; + Ok(RuleListing::Present { + for_program: program.is_some_and(|program| text.contains(&program.to_lowercase())), + observed, + }) } +/// Whether a stored rule lets `program` receive connections: enabled, allowing, inbound and +/// for this path. A rule that was disabled or turned into a block still names the program +/// in the listing. When the store cannot be read the listing's word is taken, as before. #[cfg(any(windows, test))] +async fn stored_rule_admits(runner: &Runner, program: &str) -> bool +where + Runner: FirewallCommandRunner, +{ + let stored = match runner + .run(FirewallProgram::Reg, remote_access_rule_store_args()) + .await + { + Ok(output) if output.success => output.stdout.to_lowercase(), + _ => { + tracing::warn!( + class = "firewall-store-unreadable", + "firewall rule store not read" + ); + return true; + } + }; + let name = format!("|name={}|", REMOTE_ACCESS_RULE_NAME.to_lowercase()); + let app = format!("|app={}|", program.to_lowercase()); + stored.lines().any(|line| { + [ + name.as_str(), + app.as_str(), + "|action=allow|", + "|active=true|", + "|dir=in|", + ] + .iter() + .all(|field| line.contains(field)) + }) +} + +/// True only on positive evidence that this process runs without elevation, so an elevated +/// desktop whose change failed is never sent round a Retry that cannot help. +#[cfg(any(windows, test))] +async fn runs_unelevated(runner: &Runner) -> bool +where + Runner: FirewallCommandRunner, +{ + match runner + .run(FirewallProgram::Whoami, vec!["/groups".to_owned()]) + .await + { + Ok(output) if output.success => { + !ELEVATED_INTEGRITY_SIDS + .iter() + .any(|sid| output.stdout.contains(sid)) + && UNELEVATED_INTEGRITY_SIDS + .iter() + .any(|sid| output.stdout.contains(sid)) + } + _ => false, + } +} + +#[cfg(test)] async fn sync_remote_access_rule_with_runner( enabled: bool, runner: &Runner, resolve_program: ResolveProgram, -) -> Result<(), String> +) -> Result<(), FirewallFailure> where Runner: FirewallCommandRunner, ResolveProgram: FnOnce() -> Result, { - delete_and_verify_remote_access_rule(runner).await?; - if !enabled { - return Ok(()); - } + sync_rule(enabled, runner, None, resolve_program) + .await + .map(|_| ()) +} - let program = resolve_program()?; +/// `own_rule` is how this process's rule read back (see [`FirewallWorkerState`]). Returns +/// how a kept rule reads, or `None` when none was kept or the rule was added unread. +#[cfg(any(windows, test))] +async fn sync_rule( + enabled: bool, + runner: &Runner, + own_rule: Option, + resolve_program: ResolveProgram, +) -> Result, FirewallFailure> +where + Runner: FirewallCommandRunner, + ResolveProgram: FnOnce() -> Result, +{ + let program = if enabled { + Some(resolve_program().map_err(|_| FirewallFailure::Unavailable)?) + } else { + None + }; + let listing = read_remote_access_rule(runner, program.as_deref()).await?; + // A rule that already admits this program is kept rather than asked for again. + if let ( + RuleListing::Present { + for_program, + observed, + }, + Some(program), + ) = (listing, &program) + && (own_rule == Some(observed) + || (for_program && stored_rule_admits(runner, program).await)) + { + return Ok(Some(observed)); + } + match listing { + RuleListing::Absent => {} + RuleListing::Present { .. } => { + // A spawn failure or timeout is an Err here and stays a failed safeguard. + let deletion = runner + .run(FirewallProgram::Netsh, remote_access_rule_delete_args()) + .await + .map_err(|_| FirewallFailure::RemoveFailed)?; + let still_present = read_remote_access_rule(runner, None).await? != RuleListing::Absent; + if !still_present { + // The rule is gone, whatever the delete reported. + } else if deletion.success { + return Err(FirewallFailure::RemoveFailed); + } else if runs_unelevated(runner).await { + // The integrity level is reason enough: an unelevated process can never + // change a firewall rule (the Windows lab refused even a delete of a + // rule that did not exist for elevation), so this refusal needs elevation + // whatever text netsh printed. + return Err(if enabled { + FirewallFailure::ElevationRequired + } else { + FirewallFailure::RuleLeft + }); + } else { + return Err(FirewallFailure::RemoveFailed); + } + } + } + let Some(program) = program else { + return Ok(None); + }; let addition = runner - .run("netsh".to_owned(), remote_access_rule_add_args(&program)) - .await?; - require_firewall_command_success("add the remote access firewall rule", addition) + .run( + FirewallProgram::Netsh, + remote_access_rule_add_args(&program), + ) + .await + .map_err(|_| FirewallFailure::AddFailed)?; + if addition.success { + Ok(None) + } else if runs_unelevated(runner).await { + Err(FirewallFailure::ElevationRequired) + } else { + Err(FirewallFailure::AddFailed) + } } +/// Asks Windows to run netsh elevated: one prompt to add the rule, or two when a rule for +/// another path must be deleted first. Success is read back from the rule store. #[cfg(any(windows, test))] -fn require_firewall_command_success( - operation: &str, - output: FirewallCommandOutput, -) -> Result<(), String> { - if output.success { - return Ok(()); - } - let stderr = output.stderr.trim(); - let stdout = output.stdout.trim(); - let details = if !stderr.is_empty() { - stderr - } else if !stdout.is_empty() { - stdout - } else { - "command exited unsuccessfully without output" +async fn elevate_remote_access_rule( + runner: &Runner, + state: &Arc>, + program: &str, +) -> Result, FirewallFailure> +where + Runner: FirewallCommandRunner, +{ + let replacing = read_remote_access_rule(runner, Some(program)).await? != RuleListing::Absent; + if replacing { + let prompt = PromptGuard::open(state)?; + match runner + .run_elevated(remote_access_rule_delete_args(), prompt) + .await + { + ElevatedRun::Refused => return Err(FirewallFailure::ElevationRequired), + ElevatedRun::Exited(0) => {} + ElevatedRun::Exited(_) => return Err(FirewallFailure::RemoveFailed), + } + if read_remote_access_rule(runner, None).await? != RuleListing::Absent { + return Err(FirewallFailure::RemoveFailed); + } + } + // A prompt still on screen from the delete keeps this one from opening. + let prompt = match PromptGuard::open(state) { + Err(_) if replacing => return Err(FirewallFailure::RuleRemovedNotAdded), + prompt => prompt?, }; - Err(format!("failed to {operation}: {details}")) + let run = runner + .run_elevated(remote_access_rule_add_args(program), prompt) + .await; + // The rule was absent before this add, so any rule now is the one it added, even if + // the listing does not echo a non-ASCII path byte for byte. + match (read_remote_access_rule(runner, Some(program)).await?, run) { + (RuleListing::Present { observed, .. }, ElevatedRun::Exited(_)) => Ok(Some(observed)), + ( + RuleListing::Present { + for_program: true, + observed, + }, + ElevatedRun::Refused, + ) => Ok(Some(observed)), + // The old rule named another program path, so it is stale and not restored. + (_, ElevatedRun::Refused) if replacing => Err(FirewallFailure::RuleRemovedNotAdded), + (_, ElevatedRun::Refused) => Err(FirewallFailure::ElevationRequired), + (RuleListing::Absent, ElevatedRun::Exited(_)) => Err(FirewallFailure::AddFailed), + } } +/// `allow_prompt` is true only for the widen step a person started in the Share this host +/// dialog, which runs before and outside the exposure lock; recovery and automatic +/// narrowing never raise the Windows prompt. #[cfg(windows)] -pub(crate) async fn sync_remote_access_rule(enabled: bool) -> Result<(), String> { +pub(crate) async fn sync_remote_access_rule( + enabled: bool, + allow_prompt: bool, +) -> Result<(), String> { static WORKER: OnceLock = OnceLock::new(); let program = enabled .then(|| { std::env::current_exe() - .map_err(|error| format!("failed to resolve desktop executable: {error}")) + .map_err(|_| FirewallFailure::Unavailable.token().to_owned()) .map(|path| path.to_string_lossy().into_owned()) }) .transpose()?; WORKER .get_or_init(|| FirewallWorker::start(ProcessFirewallCommandRunner)) - .sync(enabled, program, FIREWALL_CALLER_TIMEOUT) + .sync(enabled, allow_prompt, program, FIREWALL_CALLER_TIMEOUT) .await + .map_err(|failure| { + tracing::warn!( + reason = failure.token(), + enabled, + "remote-access firewall change failed" + ); + failure.token().to_owned() + }) } #[cfg(not(windows))] -pub(crate) async fn sync_remote_access_rule(_enabled: bool) -> Result<(), String> { +pub(crate) async fn sync_remote_access_rule( + _enabled: bool, + _allow_prompt: bool, +) -> Result<(), String> { Ok(()) } @@ -463,32 +995,61 @@ mod tests { struct FakeFirewallCommandRunner { calls: Arc>>, results: Arc>>>, + elevated: Arc>>>, + elevated_outcomes: Arc>>, + hold_prompts: Arc, + held_prompts: Arc>>, } impl FakeFirewallCommandRunner { fn with_results(results: Vec>) -> Self { Self { - calls: Arc::default(), results: Arc::new(Mutex::new(results.into())), + ..Self::default() } } + /// Every elevated run (at most two) ends with `outcome`. + fn with_elevation( + results: Vec>, + outcome: ElevatedRun, + ) -> Self { + Self::with_elevations(results, vec![outcome, outcome]) + } + + fn with_elevations( + results: Vec>, + outcomes: Vec, + ) -> Self { + let runner = Self::with_results(results); + *runner.elevated_outcomes.lock().expect("elevated outcomes") = outcomes.into(); + runner + } + + fn release_held_prompts(&self) { + self.held_prompts.lock().expect("held prompts").clear(); + } + fn calls(&self) -> Vec { self.calls.lock().expect("firewall calls").clone() } + + fn elevated(&self) -> Vec> { + self.elevated.lock().expect("elevated calls").clone() + } } impl FirewallCommandRunner for FakeFirewallCommandRunner { fn run( &self, - executable: String, + program: FirewallProgram, args: Vec, ) -> impl std::future::Future> + Send { self.calls .lock() .expect("firewall calls") - .push((executable, args)); + .push((program.name().to_owned(), args)); let result = self .results .lock() @@ -497,8 +1058,81 @@ mod tests { .expect("configured firewall result"); async move { result } } + + fn run_elevated( + &self, + netsh_args: Vec, + prompt: PromptGuard, + ) -> impl std::future::Future + Send { + self.elevated + .lock() + .expect("elevated calls") + .push(netsh_args); + let outcome = self + .elevated_outcomes + .lock() + .expect("elevated outcomes") + .pop_front() + .expect("an elevated run was not expected"); + if self.hold_prompts.load(Ordering::SeqCst) { + // Models a ShellExecuteExW call still blocked after its caller gave up. + self.held_prompts.lock().expect("held prompts").push(prompt); + } + async move { outcome } + } + } + + const PROGRAM: &str = r"C:\Program Files\BiBCode\bibcode-desktop.exe"; + + /// The netsh refusal recorded in the Windows 11 lab (en-US, UAC on, not elevated). + fn elevation_refusal() -> FirewallCommandOutput { + FirewallCommandOutput { + success: false, + stdout: "The requested operation requires elevation (Run as administrator).\r\n" + .to_owned(), + stderr: String::new(), + } + } + + fn whoami(integrity_sid: &str) -> FirewallCommandOutput { + FirewallCommandOutput { + success: true, + stdout: format!( + "\"Mandatory Label\\Medium Mandatory Level\",\"Label\",\"{integrity_sid}\",\"\"\r\n" + ), + stderr: String::new(), + } + } + + fn absent() -> FirewallCommandOutput { + FirewallCommandOutput { + success: false, + stdout: super::FIREWALL_RULE_ABSENT_MARKER.to_owned(), + stderr: String::new(), + } + } + + fn listed(program: &str) -> FirewallCommandOutput { + FirewallCommandOutput { + success: true, + stdout: format!("Rule Name: BiBCode Remote Access\r\nProgram: {program}\r\n"), + stderr: String::new(), + } + } + + /// A stored rule as `reg query` printed it in the Windows 11 lab. + fn stored(fields: &str, program: &str) -> FirewallCommandOutput { + FirewallCommandOutput { + success: true, + stdout: format!( + " {{250E3705-6B03-4619-9C93-2C5D06C5115A}} REG_SZ v2.33|{fields}|Profile=Domain|Profile=Private|App={program}|Name=BiBCode Remote Access|\r\n" + ), + stderr: String::new(), + } } + const ADMITTING: &str = "Action=Allow|Active=TRUE|Dir=In"; + fn success() -> FirewallCommandOutput { FirewallCommandOutput { success: true, @@ -557,6 +1191,7 @@ mod tests { "show".to_string(), "rule".to_string(), "name=BiBCode Remote Access".to_string(), + "verbose".to_string(), ] ); assert_eq!( @@ -592,8 +1227,7 @@ mod tests { ); let error = super::rule_listing_is_absent(&failure("Access is denied.")) .expect_err("query failure"); - assert!(error.contains("delete and verify")); - assert!(error.contains("Access is denied.")); + assert_eq!(error, FirewallFailure::ReadFailed); assert!(super::listing_needs_store_probe(&failure( "Aucune règle ne correspond aux critères spécifiés." ))); @@ -645,8 +1279,7 @@ mod tests { }) .await .expect_err("unreadable store must not count as absence"); - assert!(error.contains("delete and verify")); - assert!(error.contains("Aucune règle")); + assert_eq!(error, FirewallFailure::ReadFailed); assert_eq!(denied.calls().len(), 2); assert_eq!(denied.calls()[1].1, remote_access_store_probe_args()); } @@ -695,7 +1328,7 @@ mod tests { .await .expect_err("spawn failure must be reported"); - assert!(error.contains("launch denied")); + assert_eq!(error, FirewallFailure::ReadFailed); assert_eq!(runner.calls().len(), 1); assert_eq!(runner.calls()[0].0, "netsh"); } @@ -713,8 +1346,7 @@ mod tests { .await .expect_err("policy denial must be reported"); - assert!(error.contains("delete and verify")); - assert!(error.contains("Access is denied.")); + assert_eq!(error, FirewallFailure::ReadFailed); assert_eq!(runner.calls().len(), 2); assert_eq!(runner.calls()[0].1, remote_access_rule_show_args()); assert_eq!(runner.calls()[1].1, remote_access_store_probe_args()); @@ -753,15 +1385,15 @@ mod tests { let program = Some(r"C:\Apps\BiBCode\bibcode-desktop.exe".to_owned()); worker - .sync(true, program.clone(), FIREWALL_CALLER_TIMEOUT) + .sync(true, false, program.clone(), FIREWALL_CALLER_TIMEOUT) .await .expect("first enable"); worker - .sync(true, program, FIREWALL_CALLER_TIMEOUT) + .sync(true, false, program, FIREWALL_CALLER_TIMEOUT) .await .expect("idempotent replacement"); worker - .sync(false, None, FIREWALL_CALLER_TIMEOUT) + .sync(false, false, None, FIREWALL_CALLER_TIMEOUT) .await .expect("verified disable"); @@ -786,14 +1418,14 @@ mod tests { let error = worker .sync( true, + false, Some(r"C:\Apps\BiBCode\bibcode-desktop.exe".to_owned()), FIREWALL_CALLER_TIMEOUT, ) .await .expect_err("failed deletion must fail closed"); - assert!(error.contains("delete and verify")); - assert!(error.contains("rule is still present")); + assert_eq!(error, FirewallFailure::ReadFailed); assert_eq!(runner.calls().len(), 2); assert_eq!(runner.calls()[0].1, remote_access_rule_show_args()); assert_eq!(runner.calls()[1].1, remote_access_store_probe_args()); @@ -833,7 +1465,7 @@ mod tests { impl FirewallCommandRunner for LateSpawnFirewallCommandRunner { fn run( &self, - executable: String, + program: FirewallProgram, args: Vec, ) -> impl std::future::Future> + Send { @@ -842,7 +1474,7 @@ mod tests { self.calls .lock() .expect("firewall calls") - .push((executable.clone(), args)); + .push((program.name().to_owned(), args)); let release_add = self.release_add.clone(); async move { if should_block { @@ -855,6 +1487,14 @@ mod tests { Ok(success()) } } + + async fn run_elevated( + &self, + _netsh_args: Vec, + _prompt: PromptGuard, + ) -> ElevatedRun { + panic!("no elevation is expected here") + } } async fn wait_for_firewall_calls(runner: &LateSpawnFirewallCommandRunner, expected: usize) { @@ -896,6 +1536,7 @@ mod tests { first_worker .sync( true, + false, Some(r"C:\Apps\BiBCode\bibcode-desktop.exe".to_owned()), FIREWALL_CALLER_TIMEOUT, ) @@ -908,13 +1549,14 @@ mod tests { .await .expect("caller task") .expect_err("caller deadline should expire while command spawn is outstanding"); - assert!(error.contains("timed out")); + assert_eq!(error, FirewallFailure::TimedOut); let later_worker = worker.clone(); let later = tokio::spawn(async move { later_worker .sync( true, + false, Some(r"C:\Apps\BiBCode\bibcode-desktop.exe".to_owned()), FIREWALL_CALLER_TIMEOUT, ) @@ -950,7 +1592,7 @@ mod tests { let active_program = program.clone(); let active = tokio::spawn(async move { active_worker - .sync(true, active_program, Duration::from_secs(30)) + .sync(true, false, active_program, Duration::from_secs(30)) .await }); wait_for_firewall_calls(&runner, 2).await; @@ -958,7 +1600,7 @@ mod tests { let superseded_worker = worker.clone(); let superseded = tokio::spawn(async move { superseded_worker - .sync(true, program, Duration::from_secs(30)) + .sync(true, false, program, Duration::from_secs(30)) .await }); wait_for_pending_firewall_job(&worker).await; @@ -966,7 +1608,7 @@ mod tests { let final_worker = worker.clone(); let final_job = tokio::spawn(async move { final_worker - .sync(false, None, Duration::from_secs(30)) + .sync(false, false, None, Duration::from_secs(30)) .await }); @@ -975,7 +1617,7 @@ mod tests { .expect("a replaced queued caller must be released promptly") .expect("superseded caller task") .expect_err("the replaced job must report explicit overload"); - assert!(superseded_error.contains("superseded")); + assert_eq!(superseded_error, FirewallFailure::Unavailable); runner.release_add(); active @@ -997,4 +1639,687 @@ mod tests { "only the in-flight operation and latest queued state should execute" ); } + + fn resolve_program() -> Result { + Ok(PROGRAM.to_owned()) + } + + #[tokio::test] + async fn a_refused_add_is_elevation_required_only_when_this_process_is_unelevated() { + // German netsh text: the classification never reads the sentence. + let localized = FirewallCommandOutput { + success: false, + stdout: "Für den angeforderten Vorgang sind erhöhte Rechte erforderlich.\r\n" + .to_owned(), + stderr: String::new(), + }; + for refusal in [elevation_refusal(), localized] { + let runner = FakeFirewallCommandRunner::with_results(vec![ + Ok(absent()), + Ok(refusal.clone()), + Ok(whoami("S-1-16-8192")), + ]); + let error = sync_remote_access_rule_with_runner(true, &runner, resolve_program) + .await + .expect_err("an unelevated add is refused"); + assert_eq!(error, FirewallFailure::ElevationRequired); + assert_eq!(error.token(), "firewall-elevation-required"); + assert_eq!( + runner.calls()[2], + ("whoami".to_owned(), vec!["/groups".to_owned()]) + ); + + // An elevated desktop whose add fails gets the plain failure, never Retry advice. + let elevated = FakeFirewallCommandRunner::with_results(vec![ + Ok(absent()), + Ok(refusal), + Ok(whoami("S-1-16-12288")), + ]); + assert_eq!( + sync_remote_access_rule_with_runner(true, &elevated, resolve_program).await, + Err(FirewallFailure::AddFailed) + ); + } + + let unknown = FakeFirewallCommandRunner::with_results(vec![ + Ok(absent()), + Ok(elevation_refusal()), + Err("whoami missing".to_owned()), + ]); + assert_eq!( + sync_remote_access_rule_with_runner(true, &unknown, resolve_program).await, + Err(FirewallFailure::AddFailed) + ); + } + + #[tokio::test] + async fn an_existing_rule_for_this_program_is_kept_without_any_change() { + // An unreadable store leaves the listing's word standing. + for store in [ + Ok(stored(ADMITTING, PROGRAM)), + Err("reg missing".to_owned()), + ] { + let runner = FakeFirewallCommandRunner::with_results(vec![ + Ok(listed(&PROGRAM.to_uppercase())), + store, + ]); + + sync_remote_access_rule_with_runner(true, &runner, resolve_program) + .await + .expect("the program's rule already admits it"); + + assert_eq!( + runner.calls()[1], + ("reg".to_owned(), remote_access_rule_store_args()) + ); + assert_eq!(runner.calls().len(), 2); + } + } + + #[tokio::test] + async fn a_rule_that_does_not_admit_this_program_is_replaced() { + for fields in [ + "Action=Allow|Active=FALSE|Dir=In", + "Action=Block|Active=TRUE|Dir=In", + "Action=Allow|Active=TRUE|Dir=Out", + ] { + let runner = FakeFirewallCommandRunner::with_results(vec![ + Ok(listed(PROGRAM)), + Ok(stored(fields, PROGRAM)), + Ok(success()), + Ok(absent()), + Ok(success()), + ]); + + sync_remote_access_rule_with_runner(true, &runner, resolve_program) + .await + .expect("the rule is replaced"); + + let calls = runner.calls(); + assert_eq!(calls[2].1, remote_access_rule_delete_args(), "{fields}"); + assert_eq!(calls[4].1, remote_access_rule_add_args(PROGRAM), "{fields}"); + } + // Another program's enabled rule under this name does not admit this one. + let other = FakeFirewallCommandRunner::with_results(vec![ + Ok(listed(PROGRAM)), + Ok(stored(ADMITTING, r"C:\Old\bibcode-desktop.exe")), + Ok(success()), + Ok(absent()), + Ok(success()), + ]); + sync_remote_access_rule_with_runner(true, &other, resolve_program) + .await + .expect("the rule is replaced"); + assert_eq!(other.calls().len(), 5); + } + + fn narrowing() -> Result { + panic!("narrowing must not resolve the executable") + } + + #[tokio::test] + async fn narrowing_without_administrator_permission_reports_the_rule_left_in_place() { + let unelevated = FakeFirewallCommandRunner::with_results(vec![ + Ok(listed(PROGRAM)), + Ok(elevation_refusal()), + Ok(listed(PROGRAM)), + Ok(whoami("S-1-16-8192")), + ]); + let error = sync_remote_access_rule_with_runner(false, &unelevated, narrowing) + .await + .expect_err("the delete is refused"); + assert_eq!(error, FirewallFailure::RuleLeft); + assert_eq!(error.token(), FIREWALL_RULE_LEFT_TOKEN); + + // An unelevated process can never change a rule, so any refused delete whose + // rule is still present is the tolerated case, whatever netsh printed. + let other_text = FakeFirewallCommandRunner::with_results(vec![ + Ok(listed(PROGRAM)), + Ok(failure("The object is in use.")), + Ok(listed(PROGRAM)), + Ok(whoami("S-1-16-8192")), + ]); + assert_eq!( + sync_remote_access_rule_with_runner(false, &other_text, narrowing).await, + Err(FirewallFailure::RuleLeft) + ); + + // An elevated desktop's failed delete stays a failed safeguard. + let elevated = FakeFirewallCommandRunner::with_results(vec![ + Ok(listed(PROGRAM)), + Ok(failure("The object is in use.")), + Ok(listed(PROGRAM)), + Ok(whoami("S-1-16-12288")), + ]); + assert_eq!( + sync_remote_access_rule_with_runner(false, &elevated, narrowing).await, + Err(FirewallFailure::RemoveFailed) + ); + } + + #[tokio::test] + async fn only_a_delete_that_ran_and_left_a_readable_rule_is_tolerated() { + // The delete did not run (spawn failure or the runner's timeout). + let not_run = FakeFirewallCommandRunner::with_results(vec![ + Ok(listed(PROGRAM)), + Err("timed out".to_owned()), + ]); + assert_eq!( + sync_remote_access_rule_with_runner(false, ¬_run, narrowing).await, + Err(FirewallFailure::RemoveFailed) + ); + assert_eq!(not_run.calls().len(), 2, "no integrity check without a run"); + + // The read-back failed. + let unread = FakeFirewallCommandRunner::with_results(vec![ + Ok(listed(PROGRAM)), + Ok(elevation_refusal()), + Ok(failure("Access is denied.")), + Ok(failure("Access is denied.")), + ]); + assert_eq!( + sync_remote_access_rule_with_runner(false, &unread, narrowing).await, + Err(FirewallFailure::ReadFailed) + ); + + // The rule is gone after all: narrowing succeeded. + let gone = FakeFirewallCommandRunner::with_results(vec![ + Ok(listed(PROGRAM)), + Ok(elevation_refusal()), + Ok(absent()), + ]); + assert_eq!( + sync_remote_access_rule_with_runner(false, &gone, narrowing).await, + Ok(()) + ); + } + + #[tokio::test] + async fn a_prompted_enable_runs_one_elevated_add_and_reads_the_rule_back() { + let runner = FakeFirewallCommandRunner::with_elevation( + vec![ + Ok(absent()), + Ok(elevation_refusal()), + Ok(whoami("S-1-16-8192")), + Ok(absent()), + Ok(listed(PROGRAM)), + ], + ElevatedRun::Exited(0), + ); + let worker = FirewallWorker::start(runner.clone()); + + worker + .sync( + true, + true, + Some(PROGRAM.to_owned()), + FIREWALL_CALLER_TIMEOUT, + ) + .await + .expect("the approved prompt adds the rule"); + + assert_eq!( + runner.elevated(), + vec![remote_access_rule_add_args(PROGRAM)] + ); + } + + #[tokio::test] + async fn a_declined_or_timed_out_prompt_is_one_refusal_and_is_not_retried() { + let runner = FakeFirewallCommandRunner::with_elevation( + vec![ + Ok(absent()), + Ok(elevation_refusal()), + Ok(whoami("S-1-16-8192")), + Ok(absent()), + Ok(absent()), + ], + ElevatedRun::Refused, + ); + let worker = FirewallWorker::start(runner.clone()); + + let error = worker + .sync( + true, + true, + Some(PROGRAM.to_owned()), + FIREWALL_CALLER_TIMEOUT, + ) + .await + .expect_err("a declined prompt is a refusal"); + + assert_eq!(error, FirewallFailure::ElevationRequired); + assert_eq!(runner.elevated().len(), 1); + assert_eq!(runner.calls().len(), 5, "nothing runs after the refusal"); + } + + #[tokio::test] + async fn the_read_back_trusts_a_new_rule_but_not_an_old_one_left_by_a_failed_replace() { + // A listing that does not echo the path (a non-ASCII profile name, say). + let unmatched = || listed(r"C:\Users\?????\bibcode-desktop.exe"); + let added = FakeFirewallCommandRunner::with_elevation( + vec![Ok(absent()), Ok(unmatched())], + ElevatedRun::Exited(0), + ); + let state = Arc::new(Mutex::new(FirewallWorkerState::default())); + assert!( + elevate_remote_access_rule(&added, &state, PROGRAM) + .await + .is_ok_and(|observed| observed.is_some()) + ); + + let old = r"C:\Old\bibcode-desktop.exe"; + let replaced = FakeFirewallCommandRunner::with_elevation( + vec![Ok(listed(old)), Ok(listed(old))], + ElevatedRun::Exited(0), + ); + assert_eq!( + elevate_remote_access_rule(&replaced, &state, PROGRAM).await, + Err(FirewallFailure::RemoveFailed) + ); + assert_eq!(replaced.elevated().len(), 1, "no add after a failed delete"); + } + + #[tokio::test] + async fn a_rule_for_another_program_is_replaced_with_one_prompt_per_netsh_run() { + let other = r"C:\Old\bibcode-desktop.exe"; + let runner = FakeFirewallCommandRunner::with_elevation( + vec![ + Ok(listed(other)), + Ok(elevation_refusal()), + Ok(listed(other)), + Ok(whoami("S-1-16-8192")), + Ok(listed(other)), + Ok(absent()), + Ok(listed(PROGRAM)), + ], + ElevatedRun::Exited(0), + ); + let worker = FirewallWorker::start(runner.clone()); + + worker + .sync( + true, + true, + Some(PROGRAM.to_owned()), + FIREWALL_CALLER_TIMEOUT, + ) + .await + .expect("two prompts replace the rule"); + + assert_eq!( + runner.elevated(), + vec![ + remote_access_rule_delete_args(), + remote_access_rule_add_args(PROGRAM) + ] + ); + } + + #[tokio::test] + async fn only_a_permitted_enable_with_no_prompt_open_may_elevate() { + let refused = || { + vec![ + Ok(absent()), + Ok(elevation_refusal()), + Ok(whoami("S-1-16-8192")), + ] + }; + let not_permitted = FakeFirewallCommandRunner::with_results(refused()); + let worker = FirewallWorker::start(not_permitted.clone()); + assert_eq!( + worker + .sync( + true, + false, + Some(PROGRAM.to_owned()), + FIREWALL_CALLER_TIMEOUT + ) + .await, + Err(FirewallFailure::ElevationRequired) + ); + assert!(not_permitted.elevated().is_empty()); + + let mut second_results = refused(); + second_results.push(Ok(absent())); + let second = FakeFirewallCommandRunner::with_results(second_results); + let worker = FirewallWorker::start(second.clone()); + worker + .state + .lock() + .expect("firewall worker state") + .prompt_open = true; + assert_eq!( + worker + .sync( + true, + true, + Some(PROGRAM.to_owned()), + FIREWALL_CALLER_TIMEOUT + ) + .await, + Err(FirewallFailure::PromptOpen) + ); + assert_eq!(FirewallFailure::PromptOpen.token(), "firewall-prompt-open"); + assert!( + second.elevated().is_empty(), + "a widen queued while a prompt is open must not open a second one" + ); + } + + #[tokio::test] + async fn a_prompt_still_on_screen_after_its_caller_gave_up_blocks_a_second_one() { + let refused_then_prompt = || { + vec![ + Ok(absent()), + Ok(elevation_refusal()), + Ok(whoami("S-1-16-8192")), + Ok(absent()), + Ok(absent()), + ] + }; + let mut results = refused_then_prompt(); + results.extend(refused_then_prompt()); + let runner = FakeFirewallCommandRunner::with_elevations( + results, + vec![ElevatedRun::Refused, ElevatedRun::Exited(0)], + ); + runner.hold_prompts.store(true, Ordering::SeqCst); + let worker = FirewallWorker::start(runner.clone()); + let widen = || { + worker.sync( + true, + true, + Some(PROGRAM.to_owned()), + FIREWALL_CALLER_TIMEOUT, + ) + }; + + // The caller is released with the refusal while the prompt is still up. + assert_eq!(widen().await, Err(FirewallFailure::ElevationRequired)); + assert_eq!(widen().await, Err(FirewallFailure::PromptOpen)); + assert_eq!( + runner.elevated().len(), + 1, + "no second prompt while one is open" + ); + + runner.release_held_prompts(); + assert!( + !worker + .state + .lock() + .expect("firewall worker state") + .prompt_open + ); + } + + #[tokio::test] + async fn a_declined_add_after_an_approved_delete_reports_the_partial_change() { + let other = r"C:\Old\bibcode-desktop.exe"; + let runner = FakeFirewallCommandRunner::with_elevations( + vec![ + Ok(listed(other)), + Ok(elevation_refusal()), + Ok(listed(other)), + Ok(whoami("S-1-16-8192")), + Ok(listed(other)), + Ok(absent()), + Ok(absent()), + ], + vec![ElevatedRun::Exited(0), ElevatedRun::Refused], + ); + let worker = FirewallWorker::start(runner.clone()); + + let error = worker + .sync( + true, + true, + Some(PROGRAM.to_owned()), + FIREWALL_CALLER_TIMEOUT, + ) + .await + .expect_err("the add was declined"); + + assert_eq!(error, FirewallFailure::RuleRemovedNotAdded); + assert_eq!(error.token(), "firewall-rule-removed-not-added"); + assert_eq!(runner.elevated().len(), 2, "the stale rule is not restored"); + } + + #[tokio::test] + async fn a_prompted_widen_and_its_locked_check_agree_on_a_non_ascii_path() { + let program = "C:\\Users\\T\u{e9}st \u{30e6}\u{30fc}\u{30b6}\\bibcode-desktop.exe"; + // netsh output read back through another code page loses the non-ASCII letters. + let mangled = || listed(r"C:\Users\T?st ???\bibcode-desktop.exe"); + let runner = FakeFirewallCommandRunner::with_elevation( + vec![ + // The prompted step before the lock: refused add, one approved prompt. + Ok(absent()), + Ok(elevation_refusal()), + Ok(whoami("S-1-16-8192")), + Ok(absent()), + Ok(mangled()), + // The check under the exposure lock, before the restart. + Ok(mangled()), + ], + ElevatedRun::Exited(0), + ); + let worker = FirewallWorker::start(runner.clone()); + + worker + .sync( + true, + true, + Some(program.to_owned()), + FIREWALL_CALLER_TIMEOUT, + ) + .await + .expect("the approved prompt adds the rule"); + worker + .sync( + true, + false, + Some(program.to_owned()), + FIREWALL_CALLER_TIMEOUT, + ) + .await + .expect("the locked check keeps the rule this process added"); + + assert_eq!(runner.calls().len(), 6, "no delete of its own rule"); + assert_eq!(runner.elevated().len(), 1); + } + + #[tokio::test] + async fn a_same_name_rule_replaced_for_another_program_is_not_taken_for_ours() { + let program = "C:\\Users\\T\u{e9}st \u{30e6}\u{30fc}\u{30b6}\\bibcode-desktop.exe"; + let mangled = || listed(r"C:\Users\T?st ???\bibcode-desktop.exe"); + let other = || listed(r"C:\Other\tool.exe"); + let runner = FakeFirewallCommandRunner::with_elevation( + vec![ + // The prompted add, read back as the mangled listing. + Ok(absent()), + Ok(elevation_refusal()), + Ok(whoami("S-1-16-8192")), + Ok(absent()), + Ok(mangled()), + // Someone replaced the rule for another program before the locked check. + Ok(other()), + Ok(elevation_refusal()), + Ok(other()), + Ok(whoami("S-1-16-8192")), + ], + ElevatedRun::Exited(0), + ); + let worker = FirewallWorker::start(runner.clone()); + + worker + .sync( + true, + true, + Some(program.to_owned()), + FIREWALL_CALLER_TIMEOUT, + ) + .await + .expect("the approved prompt adds the rule"); + assert_eq!( + worker + .sync( + true, + false, + Some(program.to_owned()), + FIREWALL_CALLER_TIMEOUT + ) + .await, + Err(FirewallFailure::ElevationRequired), + "another program's rule under the same name is not ours" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_prompt_permitted_job_keeps_the_caller_deadline_for_non_interactive_steps() { + let runner = LateSpawnFirewallCommandRunner::default(); + runner.block_next_add(); + let worker = FirewallWorker::start(runner.clone()); + let caller = worker.clone(); + let job = tokio::spawn(async move { + caller + .sync( + true, + true, + Some(PROGRAM.to_owned()), + FIREWALL_CALLER_TIMEOUT, + ) + .await + }); + wait_for_firewall_calls(&runner, 2).await; + + tokio::time::advance(FIREWALL_CALLER_TIMEOUT + Duration::from_secs(1)).await; + runner.release_add(); + + assert_eq!( + job.await.expect("caller task"), + Err(FirewallFailure::TimedOut), + "an add that finishes after 5 seconds is late even when a prompt was allowed" + ); + wait_for_firewall_calls(&runner, 3).await; + assert_eq!(runner.calls()[2].1, remote_access_rule_show_args()); + } + + /// Hostile install paths: each must reach netsh as one unchanged argument. + const HOSTILE_PATHS: [&str; 6] = [ + r"C:\Program Files\Test User's App\bibcode-desktop.exe", + "C:\\Program Files\\Test User\u{2019}; Write-Output 1; #\\bibcode-desktop.exe", + "C:\\Apps\\\u{2018}a\u{201a}b\u{201b}c\\bibcode-desktop.exe", + r"C:\Apps\$env:x `y & (z) 100%\bibcode-desktop.exe", + "C:\\Users\\T\u{e9}st \u{30e6}\u{30fc}\u{30b6}\\bibcode-desktop.exe", + r"C:\Apps\Trailing Space\\bibcode-desktop.exe", + ]; + + #[test] + fn netsh_arguments_follow_the_windows_command_line_rules_and_carry_any_path_as_data() { + let quoted = |path: &str| { + windows_command_line(&remote_access_rule_add_args(path)).expect("valid path") + }; + let prefix = + "advfirewall firewall add rule \"name=BiBCode Remote Access\" dir=in action=allow "; + let suffix = " protocol=TCP profile=domain,private enable=yes"; + for path in HOSTILE_PATHS { + let expected = if path.contains(' ') { + format!("{prefix}\"program={path}\"{suffix}") + } else { + format!("{prefix}program={path}{suffix}") + }; + assert_eq!(quoted(path), expected); + } + assert_eq!( + quoted("C:\\Apps\\\u{2018}a\u{201a}b\u{201b}c\\bibcode-desktop.exe"), + format!( + "{prefix}program=C:\\Apps\\\u{2018}a\u{201a}b\u{201b}c\\bibcode-desktop.exe{suffix}" + ) + ); + // Backslashes before a closing quote are doubled; others stay as they are. + assert_eq!( + windows_command_line(&["a b\\".to_owned(), String::new(), "c\\d".to_owned()]), + Ok("\"a b\\\\\" \"\" c\\d".to_owned()) + ); + assert_eq!( + windows_command_line(&remote_access_rule_add_args("C:\\a\"b\\x.exe")), + Err(FirewallFailure::Unavailable) + ); + assert_eq!( + windows_command_line(&["a\0b".to_owned()]), + Err(FirewallFailure::Unavailable) + ); + } + + #[test] + fn every_program_runs_from_the_given_system_directory() { + let system = std::path::Path::new(r"C:\Windows\System32"); + assert_eq!( + system_program_path(system, FirewallProgram::Netsh), + system.join("netsh.exe") + ); + assert_eq!( + system_program_path(system, FirewallProgram::Whoami), + system.join("whoami.exe") + ); + assert_eq!(ELEVATION_PROMPT_TIMEOUT, Duration::from_secs(120)); + } + + #[cfg(windows)] + #[test] + fn every_spawned_program_is_absolute_and_under_the_api_system_directory() { + let system = system_directory().expect("GetSystemDirectoryW"); + assert!(system.is_absolute()); + for program in [ + FirewallProgram::Netsh, + FirewallProgram::Whoami, + FirewallProgram::Reg, + ] { + let path = system_program_path(&system, program); + assert!(path.is_absolute()); + assert_eq!(path.parent(), Some(system.as_path())); + assert!(path.is_file(), "{}", path.display()); + } + } + + /// Unelevated, no prompt: starts the `firewall_argv_echo` example through the same + /// `ShellExecuteExW` path with the `open` verb, and compares the arguments it received + /// with the ones built for netsh, UTF-16 unit for unit. + #[cfg(windows)] + #[test] + #[ignore = "Windows VM probe; build the firewall_argv_echo example first"] + fn elevation_arguments_reach_a_stand_in_program_byte_for_byte() { + let echo = std::env::current_exe() + .expect("test executable") + .parent() + .and_then(std::path::Path::parent) + .expect("target directory") + .join("examples") + .join("firewall_argv_echo.exe"); + assert!(echo.is_file(), "missing {}", echo.display()); + let directory = tempfile::tempdir().expect("temporary directory"); + for (index, path) in HOSTILE_PATHS.iter().enumerate() { + let args = remote_access_rule_add_args(path); + let output = directory.path().join(format!("argv-{index}.json")); + // SAFETY: the probe runs alone; the child inherits this variable. + unsafe { std::env::set_var("BIBCODE_ARGV_ECHO_OUT", &output) }; + let run = shell_execute_and_wait( + "open", + &echo, + &windows_command_line(&args).expect("valid path"), + Duration::from_secs(30), + ) + .expect("stand-in started"); + assert_eq!(run, ShellRun::Exited(0)); + let received: Vec> = + serde_json::from_slice(&std::fs::read(&output).expect("echo output")) + .expect("echo JSON"); + let expected: Vec> = args + .iter() + .map(|arg| arg.encode_utf16().collect()) + .collect(); + assert_eq!(received, expected, "{path}"); + } + } } diff --git a/apps/desktop/src-tauri/src/server_exposure.rs b/apps/desktop/src-tauri/src/server_exposure.rs index b5cfcd654c..165afd7421 100644 --- a/apps/desktop/src-tauri/src/server_exposure.rs +++ b/apps/desktop/src-tauri/src/server_exposure.rs @@ -47,6 +47,26 @@ pub(crate) async fn apply_exposure( .await } +/// Widens after `before_lock`, a firewall step that may wait for a person at the Windows +/// prompt. It runs before the exposure lock and before any restart, so other exposure +/// changes are not held behind the prompt, and a refusal leaves nothing to recover. +pub(crate) async fn apply_exposure_after( + coordinator: &ServerExposureCoordinator, + operations: &impl ExposureOperations, + desired: &str, + before_lock: impl Future>, +) -> Result { + if desired == "network-accessible" + && operations.native_exposure_available()? + && let Err(error) = before_lock.await + { + return Err(format!( + "Could not apply server exposure {desired}: open remote-access firewall rule: {error}. No exposure change was made." + )); + } + apply_exposure(coordinator, operations, desired).await +} + pub(crate) async fn recover_local( operations: &impl ExposureOperations, ) -> Result { @@ -80,21 +100,26 @@ async fn apply_exposure_locked( if desired == "network-accessible" { if errors.is_empty() { - let restarted = match operations.restart_with_mode(desired).await { - Ok(restarted) => restarted.or_else(|| operations.current_config()), - Err(error) => { - errors.push(format!("restart network-accessible backend: {error}")); - None + // The rule names the program, not the port, so it is checked before the wide + // restart: a rule that fails here never widens the server, though the recovery + // below still restarts it local-only. + if let Err(error) = operations.sync_firewall(true).await { + errors.push(format!("open remote-access firewall rule: {error}")); + } + let restarted = if errors.is_empty() { + match operations.restart_with_mode(desired).await { + Ok(restarted) => restarted.or_else(|| operations.current_config()), + Err(error) => { + errors.push(format!("restart network-accessible backend: {error}")); + None + } } + } else { + None }; if errors.is_empty() && !is_verified_wide(restarted.as_ref()) { errors.push("restart did not produce a network-accessible endpoint".to_owned()); } - if errors.is_empty() - && let Err(error) = operations.sync_firewall(true).await - { - errors.push(format!("open remote-access firewall rule: {error}")); - } if errors.is_empty() && let Err(error) = operations.persist_mode(desired).await { @@ -173,8 +198,15 @@ async fn recover_local_steps(operations: &impl ExposureOperations) -> LocalRecov None } }; - if let Err(error) = operations.sync_firewall(false).await { - errors.push(format!("close remote-access firewall rule: {error}")); + match operations.sync_firewall(false).await { + Ok(()) => {} + // Windows keeps a rule an unelevated desktop cannot delete; the program-scoped rule + // admits nothing once the listener below is verified loopback-only. + Err(error) if error == crate::firewall::FIREWALL_RULE_LEFT_TOKEN => tracing::warn!( + reason = %error, + "remote-access firewall rule left in place without administrator permission" + ), + Err(error) => errors.push(format!("close remote-access firewall rule: {error}")), } let current_config = operations.current_config().or(restarted); let verified_local = is_verified_local(current_config.as_ref()); @@ -219,6 +251,7 @@ mod tests { use std::{ collections::VecDeque, sync::{Arc, Mutex}, + time::Duration, }; use super::*; @@ -396,8 +429,6 @@ mod tests { server_exposure_mode: mode.to_owned(), endpoint_url: wide.then(|| "http://192.168.1.20:3773".to_owned()), advertised_host: wide.then(|| "192.168.1.20".to_owned()), - tailscale_serve_enabled: false, - tailscale_serve_port: 443, } } @@ -416,7 +447,7 @@ mod tests { } #[tokio::test] - async fn widening_orders_restart_firewall_then_persistence() { + async fn widening_orders_firewall_restart_then_persistence() { let coordinator = ServerExposureCoordinator::default(); let operations = FakeExposureOperations::with_persisted_mode("local-only"); @@ -434,8 +465,8 @@ mod tests { assert_eq!( operations.calls(), [ - "restart:network-accessible", "firewall:true", + "restart:network-accessible", "persist:network-accessible", ] ); @@ -455,8 +486,8 @@ mod tests { assert_eq!( operations.calls(), [ - "restart:network-accessible", "firewall:true", + "restart:network-accessible", "persist:network-accessible", "persist:local-only", "restart:local-only", @@ -470,7 +501,9 @@ mod tests { async fn failed_widening_attempts_every_local_safeguard_in_order_and_combines_errors() { let coordinator = ServerExposureCoordinator::default(); let operations = FakeExposureOperations::with_persisted_mode("local-only"); - operations.return_next_restart(Some(config_for_mode("network-accessible"))); + // The firewall step now runs before the restart, so the topology that recovery + // cannot verify is the one already running. + operations.set_current_config(Some(config_for_mode("network-accessible"))); operations.fail_next_firewall("wide firewall failed"); operations.fail_next_persist("local persistence failed"); operations.fail_next_restart("local restart failed"); @@ -496,7 +529,6 @@ mod tests { assert_eq!( operations.calls(), [ - "restart:network-accessible", "firewall:true", "persist:local-only", "restart:local-only", @@ -508,7 +540,7 @@ mod tests { } #[tokio::test] - async fn widening_without_an_advertised_endpoint_recovers_before_opening_the_firewall() { + async fn widening_without_an_advertised_endpoint_recovers_and_closes_the_firewall() { let coordinator = ServerExposureCoordinator::default(); let operations = FakeExposureOperations::with_persisted_mode("local-only"); let mut unadvertised = config_for_mode("network-accessible"); @@ -524,6 +556,7 @@ mod tests { assert_eq!( operations.calls(), [ + "firewall:true", "restart:network-accessible", "persist:local-only", "restart:local-only", @@ -549,8 +582,8 @@ mod tests { assert_eq!( operations.calls(), [ - "restart:network-accessible", "firewall:true", + "restart:network-accessible", "persist:network-accessible", "persist:local-only", "restart:local-only", @@ -648,10 +681,11 @@ mod tests { .await .expect_err("firewall-open failure recovers local"); assert!(error.contains("open failed")); + // A rule that fails under the lock avoids the wide restart; recovery still restarts + // the server local-only. assert_eq!( widening.calls(), [ - "restart:network-accessible", "firewall:true", "persist:local-only", "restart:local-only", @@ -682,6 +716,129 @@ mod tests { ); } + #[tokio::test] + async fn a_refused_prompt_before_the_lock_changes_nothing() { + let coordinator = ServerExposureCoordinator::default(); + let operations = FakeExposureOperations::with_persisted_mode("local-only"); + + let error = apply_exposure_after(&coordinator, &operations, "network-accessible", async { + Err("firewall-elevation-required".to_owned()) + }) + .await + .expect_err("a refused prompt fails the widen"); + + assert_eq!( + error, + "Could not apply server exposure network-accessible: open remote-access firewall rule: firewall-elevation-required. No exposure change was made." + ); + assert!(operations.calls().is_empty(), "{:?}", operations.calls()); + } + + #[tokio::test] + async fn the_prompt_step_runs_only_for_a_native_widen() { + let coordinator = ServerExposureCoordinator::default(); + let narrowing = FakeExposureOperations::with_persisted_mode("network-accessible"); + apply_exposure_after(&coordinator, &narrowing, "local-only", async { + panic!("narrowing must not prompt") + }) + .await + .expect("narrowing succeeds"); + + let wsl_only = FakeExposureOperations::with_persisted_mode("local-only"); + wsl_only.set_native_exposure_available(false); + let error = apply_exposure_after(&coordinator, &wsl_only, "network-accessible", async { + panic!("WSL-only must not prompt") + }) + .await + .expect_err("WSL-only rejects native exposure"); + assert!(error.contains("WSL-only primary mode")); + } + + #[tokio::test] + async fn other_exposure_changes_are_not_held_behind_an_open_prompt() { + let coordinator = ServerExposureCoordinator::default(); + let widening = FakeExposureOperations::with_persisted_mode("local-only"); + let narrowing = FakeExposureOperations::with_persisted_mode("network-accessible"); + let (answer, answered) = tokio::sync::oneshot::channel::<()>(); + + let prompted = apply_exposure_after(&coordinator, &widening, "network-accessible", async { + answered.await.expect("the person answers"); + Ok(()) + }); + let other = async { + apply_exposure(&coordinator, &narrowing, "local-only") + .await + .expect("a queued change runs while the prompt is open"); + answer.send(()).expect("prompt still waiting"); + }; + let (prompted, ()) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!(prompted, other) + }) + .await + .expect("no change waits behind the prompt"); + + prompted.expect("the approved widen completes"); + assert_eq!( + widening.calls(), + [ + "firewall:true", + "restart:network-accessible", + "persist:network-accessible", + ] + ); + } + + #[tokio::test] + async fn a_firewall_rule_windows_refuses_to_delete_is_tolerated_once_local_is_verified() { + let coordinator = ServerExposureCoordinator::default(); + let refused = FakeExposureOperations::with_persisted_mode("local-only"); + { + let mut state = refused.state.lock().expect("fake state"); + state + .firewall_results + .push_back(Err("firewall-elevation-required".to_owned())); + state + .firewall_results + .push_back(Err(crate::firewall::FIREWALL_RULE_LEFT_TOKEN.to_owned())); + } + let error = apply_exposure(&coordinator, &refused, "network-accessible") + .await + .expect_err("a refused widen recovers local"); + assert_eq!( + error, + "Could not apply server exposure network-accessible: open remote-access firewall rule: firewall-elevation-required. Restored local-only safeguards." + ); + + let narrowing = FakeExposureOperations::with_persisted_mode("network-accessible"); + narrowing + .state + .lock() + .expect("fake state") + .firewall_results + .push_back(Err(crate::firewall::FIREWALL_RULE_LEFT_TOKEN.to_owned())); + apply_exposure(&coordinator, &narrowing, "local-only") + .await + .expect("a rule left in place does not fail a verified local-only narrowing"); + + // The listener check stays a hard requirement even then. + let unverified = FakeExposureOperations::with_persisted_mode("network-accessible"); + unverified.set_current_config(Some(config_for_mode("network-accessible"))); + { + let mut state = unverified.state.lock().expect("fake state"); + state + .restart_results + .push_back(Ok(Some(config_for_mode("network-accessible")))); + state + .firewall_results + .push_back(Err(crate::firewall::FIREWALL_RULE_LEFT_TOKEN.to_owned())); + } + let error = apply_exposure(&coordinator, &unverified, "local-only") + .await + .expect_err("an unverified listener still fails"); + assert!(error.contains("not verified local-only"), "{error}"); + assert!(!error.contains("firewall-rule-left"), "{error}"); + } + #[tokio::test] async fn narrowing_restart_failure_closes_firewall_and_stops_the_backend() { let coordinator = ServerExposureCoordinator::default(); @@ -720,8 +877,8 @@ mod tests { let calls = operations.calls(); let widen_then_narrow = [ - "restart:network-accessible", "firewall:true", + "restart:network-accessible", "persist:network-accessible", "persist:local-only", "restart:local-only", @@ -733,8 +890,8 @@ mod tests { "restart:local-only", "firewall:false", "verify:local-only", - "restart:network-accessible", "firewall:true", + "restart:network-accessible", "persist:network-accessible", ]; assert!(calls == widen_then_narrow || calls == narrow_then_widen); @@ -764,8 +921,8 @@ mod tests { assert!( calls == [ - "restart:network-accessible", "firewall:true", + "restart:network-accessible", "persist:network-accessible", "settings:write", "settings:restart", @@ -774,8 +931,8 @@ mod tests { == [ "settings:write", "settings:restart", - "restart:network-accessible", "firewall:true", + "restart:network-accessible", "persist:network-accessible", ] ); diff --git a/apps/desktop/src-tauri/src/tailscale.rs b/apps/desktop/src-tauri/src/tailscale.rs index e1bbd335aa..aac18151fc 100644 --- a/apps/desktop/src-tauri/src/tailscale.rs +++ b/apps/desktop/src-tauri/src/tailscale.rs @@ -1,8 +1,10 @@ +use bibcode_server::diagnostics::profiler::exits::{self, Exited, Watch}; use bibcode_server::process::{configure_background_command, isolate_appimage_environment}; use serde_json::Value; use std::{ path::{Path, PathBuf}, - time::Duration, + process::ExitStatus, + time::{Duration, Instant}, }; use tokio::{ io::AsyncReadExt, @@ -11,6 +13,11 @@ use tokio::{ const DEFAULT_TAILSCALE_SERVE_PORT: u16 = 443; const TAILSCALE_STATUS_TIMEOUT: Duration = Duration::from_millis(1_500); +/// The only Tailscale command BiBCode runs. It reads; BiBCode never starts, changes or +/// stops Tailscale Serve or anything else in Tailscale. +const TAILSCALE_STATUS_ARGS: [&str; 2] = ["status", "--json"]; +/// Caps every CLI candidate together, so an address fetch waits at most this for status. +const TAILSCALE_STATUS_TOTAL_TIMEOUT: Duration = Duration::from_millis(3_000); const TAILSCALE_PROBE_TIMEOUT: Duration = Duration::from_millis(2_500); #[derive(Debug, Clone, PartialEq, Eq)] @@ -115,35 +122,91 @@ pub fn build_tailscale_https_base_url( } pub async fn read_tailscale_status() -> Result { - let mut failures = Vec::new(); - for candidate in tailscale_command_candidates() { - match read_tailscale_status_with(&candidate, TAILSCALE_STATUS_TIMEOUT).await { - Ok(status) => return Ok(status), - Err(error) => failures.push(format!("{}: {error}", candidate.display())), + read_first_tailscale_status( + tailscale_command_candidates(), + TAILSCALE_STATUS_TIMEOUT, + TAILSCALE_STATUS_TOTAL_TIMEOUT, + ) + .await +} + +/// A dropped attempt kills its child (`kill_on_drop`), so the total cap leaves nothing running. +async fn read_first_tailscale_status( + candidates: Vec, + timeout: Duration, + total_timeout: Duration, +) -> Result { + let attempts = async { + let mut failures = Vec::new(); + for candidate in candidates { + match read_tailscale_status_with(&candidate, timeout).await { + Ok(status) => return Ok(status), + Err(error) => failures.push(format!("{}: {error}", candidate.display())), + } } - } - Err(format!( - "Could not read Tailscale status from any known CLI location: {}", - failures.join("; ") - )) + Err(format!( + "Could not read Tailscale status from any known CLI location: {}", + failures.join("; ") + )) + }; + tokio::time::timeout(total_timeout, attempts) + .await + .unwrap_or_else(|_| { + Err(format!( + "tailscale status timed out after {}ms in total.", + total_timeout.as_millis() + )) + }) +} + +/// A missing, logged-out or stopped Tailscale exits non-zero on every fetch, which is +/// normal; only a signal or Windows exception death is a crash. +fn report_tailscale_exit(command_path: &Path, pid: u32, started: Instant, status: &ExitStatus) { + let exe = command_path.to_string_lossy(); + exits::report_exit( + Watch::Signals, + &Exited { + kind: "command", + label: "tailscale", + exe: &exe, + pid, + started, + }, + status, + false, + ); } async fn read_tailscale_status_with( command_path: &Path, timeout: Duration, +) -> Result { + read_tailscale_status_reporting(command_path, timeout, report_tailscale_exit).await +} + +async fn read_tailscale_status_reporting( + command_path: &Path, + timeout: Duration, + report_exit: impl FnOnce(&Path, u32, Instant, &ExitStatus), ) -> Result { let mut command = Command::new(command_path); configure_background_command(&mut command); command.kill_on_drop(true); isolate_appimage_environment(&mut command); let child = command - .args(["status", "--json"]) + .args(TAILSCALE_STATUS_ARGS) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|error| format!("Failed to spawn tailscale status: {error}"))?; + let started = Instant::now(); + let pid = child.id(); + // A timed-out child was killed on request, so only a completed run is reported. let output = wait_for_tailscale_output(child, timeout).await?; + if let Some(pid) = pid { + report_exit(command_path, pid, started, &output.status); + } decode_tailscale_status_output(output) } @@ -308,6 +371,20 @@ mod tests { assert!(!is_tailscale_ipv4_address("100.64.0.256")); } + #[test] + fn the_only_tailscale_command_is_a_read_only_status_query() { + assert_eq!(TAILSCALE_STATUS_ARGS, ["status", "--json"]); + let source = include_str!("tailscale.rs"); + // No other Tailscale invocation exists in this module (this test's own text aside). + assert_eq!( + source + .matches(concat!(".args(TAILSCALE", "_STATUS_ARGS)")) + .count(), + 1 + ); + assert!(!source.contains(concat!("\"ser", "ve\""))); + } + #[test] fn parses_status_facts() { let status = parse_tailscale_status(TAILSCALE_STATUS_JSON).expect("status should parse"); @@ -527,6 +604,52 @@ printf '%s' '{TAILSCALE_STATUS_JSON}'"# .await; } + #[cfg(unix)] + #[tokio::test] + async fn a_completed_status_child_is_reported_with_its_exit_status() { + let directory = tempfile::tempdir().unwrap(); + let failing = executable_script(directory.path(), "failing", "exit 7", "exit /b 7"); + let reported = std::sync::Mutex::new(None); + + let error = read_tailscale_status_reporting( + &failing, + Duration::from_secs(15), + |path, pid, _, status| { + *reported.lock().unwrap() = Some((path.to_path_buf(), pid, status.code())); + }, + ) + .await + .unwrap_err(); + + assert_eq!(error, "tailscale status exited with code 7."); + let (path, pid, code) = reported.lock().unwrap().take().expect("exit reported"); + assert_eq!(path, failing); + assert!(pid > 0); + assert_eq!(code, Some(7)); + } + + #[tokio::test] + async fn every_cli_candidate_together_is_bounded_by_one_total_timeout() { + let directory = tempfile::tempdir().unwrap(); + let slow = executable_script( + directory.path(), + "slow-total", + "sleep 5", + "%SystemRoot%\\System32\\ping.exe -n 6 127.0.0.1 >nul", + ); + + let error = read_first_tailscale_status( + vec![slow.clone(), slow], + Duration::from_secs(10), + Duration::from_millis(200), + ) + .await + .unwrap_err(); + + assert_eq!(error, "tailscale status timed out after 200ms in total."); + assert_eq!(TAILSCALE_STATUS_TOTAL_TIMEOUT, Duration::from_millis(3_000)); + } + #[cfg(unix)] #[tokio::test] async fn timed_out_status_child_is_killed_and_reaped_before_return() { diff --git a/apps/desktop/src-tauri/src/updates.rs b/apps/desktop/src-tauri/src/updates.rs index 5565404763..f80826d6e1 100644 --- a/apps/desktop/src-tauri/src/updates.rs +++ b/apps/desktop/src-tauri/src/updates.rs @@ -2586,8 +2586,6 @@ mod tests { server_exposure_mode: "local-only".to_string(), endpoint_url: None, advertised_host: None, - tailscale_serve_enabled: false, - tailscale_serve_port: 443, } } diff --git a/apps/server/src/agent_sessions/mod.rs b/apps/server/src/agent_sessions/mod.rs index a7c7e83a87..2789e410fd 100644 --- a/apps/server/src/agent_sessions/mod.rs +++ b/apps/server/src/agent_sessions/mod.rs @@ -16,7 +16,7 @@ use std::{ use serde::{Deserialize, Serialize}; use serde_json::Value; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use time::OffsetDateTime; use tokio_util::sync::CancellationToken; use crate::{ @@ -1160,24 +1160,10 @@ fn read_session( (!records.failed).then_some(session).flatten() } +/// The store's one timestamp form, so imported timestamps sort with the thread's other +/// timestamps (#366). fn iso(time: SystemTime) -> String { - iso_millis(OffsetDateTime::from(time)) -} - -/// `YYYY-MM-DDTHH:MM:SS.mmmZ`, as JavaScript writes it, so imported timestamps sort with the -/// thread's other timestamps. -fn iso_millis(time: OffsetDateTime) -> String { - let time = time.to_offset(time::UtcOffset::UTC); - format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z", - time.year(), - u8::from(time.month()), - time.day(), - time.hour(), - time.minute(), - time.second(), - time.millisecond() - ) + crate::persistence::format_timestamp(OffsetDateTime::from(time)) } #[derive(Debug, Default, Deserialize)] @@ -1604,8 +1590,8 @@ fn trimmed(value: Option<&str>) -> Option<&str> { fn normalize_timestamp(value: Option<&str>, fallback: &str) -> String { value - .and_then(|value| OffsetDateTime::parse(value.trim(), &Rfc3339).ok()) - .map_or_else(|| fallback.to_owned(), iso_millis) + .and_then(crate::persistence::canonical_timestamp) + .unwrap_or_else(|| fallback.to_owned()) } fn truncate_message(text: &str) -> String { diff --git a/apps/server/src/agent_sessions/tests.rs b/apps/server/src/agent_sessions/tests.rs index 9197a7835d..b3d8bca0bd 100644 --- a/apps/server/src/agent_sessions/tests.rs +++ b/apps/server/src/agent_sessions/tests.rs @@ -93,7 +93,11 @@ fn claude_keeps_visible_text_and_skips_sidechain_meta_and_compact_records() { ("assistant", "API error"), ] ); - assert_eq!(session.messages[0].created_at, "2026-10-02T10:00:00.500Z"); + // #366: imported instants are stored in the server's one timestamp form. + assert_eq!( + session.messages[0].created_at, + "2026-10-02T10:00:00.500000000Z" + ); assert_eq!(session.messages[2].created_at, FALLBACK); } diff --git a/apps/server/src/auth/http.rs b/apps/server/src/auth/http.rs index 1ad97489d7..7535509027 100644 --- a/apps/server/src/auth/http.rs +++ b/apps/server/src/auth/http.rs @@ -554,7 +554,7 @@ async fn cancel_pairing_offer( fn invalid_pairing_offer_response(detail: &str) -> Response { let trace_id = Uuid::new_v4().to_string(); - tracing::debug!(target: "bibcode_server::auth", %trace_id, "invalid pairing offer: {detail}"); + tracing::warn!(target: "bibcode_server::auth", %trace_id, "invalid pairing offer: {detail}"); error_response( StatusCode::BAD_REQUEST, "EnvironmentRequestInvalidError", @@ -2062,11 +2062,100 @@ fn non_empty(value: Option) -> Option { #[cfg(test)] mod tests { + use std::{ + io, + sync::{Arc, Mutex}, + }; + use serde::Deserialize; - use super::{MAX_PAIRING_OFFER_IDEMPOTENCY_KEY_BYTES, is_valid_pairing_offer_idempotency_key}; + use super::{ + MAX_PAIRING_OFFER_IDEMPOTENCY_KEY_BYTES, invalid_pairing_offer_response, + is_valid_pairing_offer_idempotency_key, validate_pairing_offer_input, + }; use crate::auth::service::{is_loopback_host, is_unspecified_host}; + #[derive(Clone, Default)] + struct TraceCapture(Arc>>); + + struct TraceCaptureWriter(Arc>>); + + impl io::Write for TraceCaptureWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0 + .lock() + .expect("trace capture") + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for TraceCapture { + type Writer = TraceCaptureWriter; + + fn make_writer(&'writer self) -> Self::Writer { + TraceCaptureWriter(self.0.clone()) + } + } + + impl TraceCapture { + fn text(&self) -> String { + String::from_utf8(self.0.lock().expect("trace capture").clone()) + .expect("trace capture UTF-8") + } + } + + /// #358: a refused offer is an operational failure the reader of the log needs, so it is a + /// `warn` with its fixed reason text, and never the offered input. + #[tokio::test] + async fn a_rejected_pairing_offer_is_logged_at_warn_with_its_reason_and_without_the_token() { + let capture = TraceCapture::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(capture.clone()) + .with_ansi(false) + .with_max_level(tracing::Level::DEBUG) + .finish(); + let _guard = tracing::subscriber::set_default(subscriber); + + let error = validate_pairing_offer_input( + "Test Device", + "ftp://example.test/?token=pairing-secret-marker", + "another-device", + None, + ) + .expect_err("an ftp endpoint is refused"); + for response in [ + invalid_pairing_offer_response(&error.to_string()), + invalid_pairing_offer_response("idempotency key is invalid"), + ] { + assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("response body"); + let body: serde_json::Value = serde_json::from_slice(&body).expect("JSON body"); + assert_eq!(body["reason"], "invalid_pairing_offer", "{body}"); + } + + let text = capture.text(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert!(lines.iter().all(|line| line.contains("WARN")), "{text}"); + assert!( + lines.iter().all(|line| line.contains("trace_id=")), + "{text}" + ); + assert!( + lines[0].contains("endpoint must be an http(s) URL"), + "{text}" + ); + assert!(lines[1].contains("idempotency key is invalid"), "{text}"); + assert!(!text.contains("pairing-secret-marker"), "{text}"); + } + #[derive(Deserialize)] struct PairingEndpointFixture { endpoint: String, diff --git a/apps/server/src/auth/service.rs b/apps/server/src/auth/service.rs index 221406ea1a..d15e6131bb 100644 --- a/apps/server/src/auth/service.rs +++ b/apps/server/src/auth/service.rs @@ -3045,21 +3045,50 @@ impl AuthService { mcp_named_client: None, }) } else { + log_pairing_refusal("desktop_bootstrap_expired"); Err(AuthError::InvalidCredential) }; } if let Some(repositories) = &self.repositories { + let now_iso = format_iso(now); let consumed = repositories .consume_auth_pairing_link( credential.to_owned(), proof_key_thumbprint.map(str::to_owned), - format_iso(now), - format_iso(now), + now_iso.clone(), + now_iso.clone(), ) .await - .map_err(|error| AuthError::Internal(error.to_string()))? - .ok_or(AuthError::InvalidCredential)?; + .map_err(|error| AuthError::Internal(error.to_string()))?; + let Some(consumed) = consumed else { + // The update refuses every cause alike; one read of the kept row names it. An + // unauthenticated caller can trigger this, so it stays a single query. + let class = if credential.trim().is_empty() { + "empty" + } else { + match repositories + .get_auth_pairing_link_by_credential(credential.to_owned()) + .await + { + Ok(link) => pairing_rejection_class( + credential, + link.as_ref().map(|link| RefusedPairing { + revoked: link.revoked_at.is_some(), + consumed: link.consumed_at.is_some(), + // The same text comparison as the update's `expires_at > ?`. + expired: link.expires_at <= now_iso, + proof_key_thumbprint: link.proof_key_thumbprint.as_deref(), + }), + proof_key_thumbprint, + ) + .unwrap_or("unclassified"), + Err(_) => "unclassified", + } + }; + log_pairing_refusal(class); + return Err(AuthError::InvalidCredential); + }; let pairing = pairing_record_from_persisted(consumed)?; let mut state = self.state.lock().await; state.pairings.remove(&pairing.id); @@ -3085,19 +3114,29 @@ impl AuthService { } let mut state = self.state.lock().await; - let pairing = state + let Some(pairing) = state .pairings .values_mut() .find(|pairing| pairing.credential == credential) - .ok_or(AuthError::InvalidCredential)?; - if pairing.revoked_at_ms.is_some() - || pairing.consumed_at_ms.is_some() - || pairing.expires_at_ms <= now - || pairing - .proof_key_thumbprint - .as_deref() - .is_some_and(|expected| Some(expected) != proof_key_thumbprint) - { + else { + // Revoked records are removed and the rest are pruned, so a gone record is `unknown`. + log_pairing_refusal( + pairing_rejection_class(credential, None, proof_key_thumbprint) + .unwrap_or("unclassified"), + ); + return Err(AuthError::InvalidCredential); + }; + if let Some(class) = pairing_rejection_class( + credential, + Some(RefusedPairing { + revoked: pairing.revoked_at_ms.is_some(), + consumed: pairing.consumed_at_ms.is_some(), + expired: pairing.expires_at_ms <= now, + proof_key_thumbprint: pairing.proof_key_thumbprint.as_deref(), + }), + proof_key_thumbprint, + ) { + log_pairing_refusal(class); return Err(AuthError::InvalidCredential); } pairing.consumed_at_ms = Some(now); @@ -3563,11 +3602,10 @@ pub fn owned_scopes(scopes: &[&str]) -> Vec { } #[must_use] +/// Epoch milliseconds in the store's fixed-width timestamp form; see +/// [`crate::persistence::format_timestamp`]. pub fn format_iso(epoch_ms: i64) -> String { - OffsetDateTime::from_unix_timestamp_nanos(i128::from(epoch_ms) * 1_000_000) - .ok() - .and_then(|date| date.format(&Rfc3339).ok()) - .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_owned()) + crate::persistence::format_timestamp_ms(epoch_ms) } #[must_use] @@ -3629,6 +3667,49 @@ fn constant_time_text_equal(left: &str, right: &str) -> bool { left.len() == right.len() && bool::from(left.as_bytes().ct_eq(right.as_bytes())) } +/// What a refused pairing record says about itself, for the refusal's log class. +struct RefusedPairing<'a> { + revoked: bool, + consumed: bool, + expired: bool, + proof_key_thumbprint: Option<&'a str>, +} + +/// The fixed class of a pairing credential refusal, or `None` when `link` admits it. Only the +/// empty string is told apart as malformed; any other unmatched text is `unknown`. +fn pairing_rejection_class( + credential: &str, + link: Option>, + presented_thumbprint: Option<&str>, +) -> Option<&'static str> { + if credential.trim().is_empty() { + return Some("empty"); + } + let Some(link) = link else { + return Some("unknown"); + }; + if link.revoked { + Some("revoked") + } else if link.consumed { + Some("already_used") + } else if link.expired { + Some("expired") + } else if link + .proof_key_thumbprint + .is_some_and(|expected| Some(expected) != presented_thumbprint) + { + Some("proof_key_mismatch") + } else { + None + } +} + +/// One `warn` per refused pairing credential: the class only, never the credential, a hash of +/// it, the link, the device or an address. +fn log_pairing_refusal(class: &'static str) { + tracing::warn!(target: "bibcode_server::auth", class = %class, "a pairing credential was refused"); +} + fn generate_pairing_credential() -> Result { let mut credential = String::with_capacity(PAIRING_LENGTH); while credential.len() < PAIRING_LENGTH { @@ -4022,6 +4103,278 @@ mod tests { (service, repositories, secrets) } + #[derive(Clone, Default)] + struct TraceCapture(std::sync::Arc>>); + + struct TraceCaptureWriter(std::sync::Arc>>); + + impl std::io::Write for TraceCaptureWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .lock() + .expect("trace capture") + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'writer> tracing_subscriber::fmt::MakeWriter<'writer> for TraceCapture { + type Writer = TraceCaptureWriter; + + fn make_writer(&'writer self) -> Self::Writer { + TraceCaptureWriter(self.0.clone()) + } + } + + impl TraceCapture { + fn text(&self) -> String { + String::from_utf8(self.0.lock().expect("trace capture").clone()) + .expect("trace capture UTF-8") + } + } + + /// Consumes `presented` under a fresh capture and checks the one `warn` line it must leave: + /// the class, and none of the issued credentials nor the presented text. + async fn assert_refusal_logged( + service: &AuthService, + presented: &str, + thumbprint: Option<&str>, + class: &str, + issued: &[String], + ) { + let capture = TraceCapture::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(capture.clone()) + .with_ansi(false) + .with_max_level(tracing::Level::DEBUG) + .finish(); + let _guard = tracing::subscriber::set_default(subscriber); + let result = service.consume_grant(presented, thumbprint).await; + assert!( + matches!(result, Err(AuthError::InvalidCredential)), + "{class}: the credential is refused, got {:?}", + result + .as_ref() + .map(|grant| format!("Ok(grant for subject {})", grant.subject)) + ); + let text = capture.text(); + let warnings: Vec<&str> = text.lines().filter(|line| line.contains("WARN")).collect(); + assert_eq!(warnings.len(), 1, "{class}: {text}"); + assert!(warnings[0].contains(&format!("class={class}")), "{text}"); + assert!( + warnings[0].contains("a pairing credential was refused"), + "{text}" + ); + for secret in issued + .iter() + .map(String::as_str) + .chain([presented]) + .filter(|secret| !secret.is_empty()) + { + assert!(!text.contains(secret), "{class}: a credential was logged"); + } + } + + /// The store compares these timestamps as text (`expires_at > ?`), so text order must be time + /// order. RFC 3339 with a trimmed fraction is not: `…:00Z` sorts after `…:00.001Z`. + #[test] + fn stored_timestamps_sort_as_text_in_time_order() { + let second = 1_767_225_600_000_i64; // 2026-01-01T00:00:00Z + let instants = + [0, 1, 9, 10, 100, 120, 121, 999, 1_000, 1_001].map(|offset| second + offset); + for earlier in instants { + for later in instants.into_iter().filter(|later| *later > earlier) { + assert!( + format_iso(earlier) < format_iso(later), + "{} must sort before {}", + format_iso(earlier), + format_iso(later) + ); + } + } + } + + /// A link is refused from the first millisecond after it expires, whatever the fraction of + /// its expiry. Explicit timestamps, so load cannot change the outcome. + #[tokio::test] + async fn a_pairing_link_is_refused_from_the_millisecond_after_it_expires() { + let (_service, repositories, _secrets) = mcp_test_service().await; + let second = 1_767_225_600_000_i64; + for (index, expires_at) in [second, second + 100, second + 120].into_iter().enumerate() { + let credential = format!("expiry-probe-{index}"); + repositories + .create_auth_pairing_link(PersistedPairingLink { + id: format!("expiry-probe-id-{index}"), + credential: credential.clone(), + method: "one-time-token".to_owned(), + scopes: serde_json::json!(owned_scopes(STANDARD_SCOPES)), + subject: "one-time-token".to_owned(), + label: None, + proof_key_thumbprint: None, + created_at: format_iso(expires_at - 60_000), + expires_at: format_iso(expires_at), + consumed_at: None, + revoked_at: None, + reach: None, + off_host: None, + mcp_access: None, + mcp_named_client: None, + }) + .await + .expect("the link is stored"); + let now = format_iso(expires_at + 1); + let consumed = repositories + .consume_auth_pairing_link(credential, None, now.clone(), now.clone()) + .await + .expect("the store answers"); + assert!( + consumed.is_none(), + "expires {} was accepted at {now}", + format_iso(expires_at) + ); + } + } + + fn expired_pairing(issuance: Result) -> PairingCredentialResult { + reserved_pairing(issuance.expect("an expired pairing issues")).expect("reserved") + } + + /// #358: every refusal of a persisted pairing credential names its class at `warn`. + #[tokio::test] + async fn a_refused_pairing_credential_is_logged_at_warn_with_its_class_and_never_the_credential_persisted() + { + let (service, _repositories, _secrets) = mcp_test_service().await; + let scopes = || owned_scopes(STANDARD_SCOPES); + let mut issued = Vec::new(); + + assert_refusal_logged( + &service, + "pairing-secret-marker-unknown", + None, + "unknown", + &issued, + ) + .await; + assert_refusal_logged(&service, "", None, "empty", &issued).await; + + let revoked = service.issue_pairing(scopes(), None).await.expect("issues"); + issued.push(revoked.credential.clone()); + assert!(service.revoke_pairing(&revoked.id).await.expect("revokes")); + assert_refusal_logged(&service, &revoked.credential, None, "revoked", &issued).await; + + let used = service.issue_pairing(scopes(), None).await.expect("issues"); + issued.push(used.credential.clone()); + assert!(service.consume_grant(&used.credential, None).await.is_ok()); + assert_refusal_logged(&service, &used.credential, None, "already_used", &issued).await; + + let expired = expired_pairing( + service + .issue_pairing_for_subject( + scopes(), + None, + "one-time-token", + -1, + AuthGrantMetadata::default(), + None, + ) + .await, + ); + issued.push(expired.credential.clone()); + assert_refusal_logged(&service, &expired.credential, None, "expired", &issued).await; + + let proven = service + .issue_pairing_with_proof(scopes(), None, "thumbprint-a".to_owned()) + .await + .expect("issues"); + issued.push(proven.credential.clone()); + assert_refusal_logged( + &service, + &proven.credential, + Some("thumbprint-b"), + "proof_key_mismatch", + &issued, + ) + .await; + } + + /// #358: the in-memory store can name fewer classes, because it forgets refused records. + #[tokio::test] + async fn a_refused_pairing_credential_is_logged_at_warn_with_its_class_and_never_the_credential_in_memory() + { + let config = ServerConfig::new(".").with_bind("127.0.0.1", 3773); + let mut service = AuthService::new(&config, vec![7_u8; 32]); + service.desktop_bootstrap = Some(DesktopBootstrap { + credential: "pairing-secret-marker-desktop".into(), + expires_at_ms: 0, + }); + let scopes = || owned_scopes(STANDARD_SCOPES); + let mut issued = vec!["pairing-secret-marker-desktop".to_owned()]; + + assert_refusal_logged( + &service, + "pairing-secret-marker-unknown", + None, + "unknown", + &issued, + ) + .await; + assert_refusal_logged(&service, "", None, "empty", &issued).await; + + // `revoke_pairing` removes the in-memory record, so nothing is left to name the cause. + let revoked = service.issue_pairing(scopes(), None).await.expect("issues"); + issued.push(revoked.credential.clone()); + assert!(service.revoke_pairing(&revoked.id).await.expect("revokes")); + assert_refusal_logged(&service, &revoked.credential, None, "unknown", &issued).await; + + // Consumed and expired records survive until the next issue or list prunes them. + let used = service.issue_pairing(scopes(), None).await.expect("issues"); + issued.push(used.credential.clone()); + assert!(service.consume_grant(&used.credential, None).await.is_ok()); + assert_refusal_logged(&service, &used.credential, None, "already_used", &issued).await; + + let expired = expired_pairing( + service + .issue_pairing_for_subject( + scopes(), + None, + "one-time-token", + -1, + AuthGrantMetadata::default(), + None, + ) + .await, + ); + issued.push(expired.credential.clone()); + assert_refusal_logged(&service, &expired.credential, None, "expired", &issued).await; + + let proven = service + .issue_pairing_with_proof(scopes(), None, "thumbprint-a".to_owned()) + .await + .expect("issues"); + issued.push(proven.credential.clone()); + assert_refusal_logged( + &service, + &proven.credential, + Some("thumbprint-b"), + "proof_key_mismatch", + &issued, + ) + .await; + + assert_refusal_logged( + &service, + "pairing-secret-marker-desktop", + None, + "desktop_bootstrap_expired", + &issued, + ) + .await; + } + #[tokio::test] async fn the_mcp_issuer_adds_only_the_terminal_scope() { let (service, repositories, _secrets) = mcp_test_service().await; @@ -6409,7 +6762,15 @@ mod tests { } assert!(decode_persisted_scopes(serde_json::json!(42)).is_err()); assert!(parse_timestamp_ms("not-a-timestamp").is_err()); - assert_eq!(format_iso(i64::MAX), "1970-01-01T00:00:00Z"); + assert_eq!(format_iso(i64::MAX), "1970-01-01T00:00:00.000000000Z"); + assert_eq!( + format_iso(1_767_225_600_120), + "2026-01-01T00:00:00.120000000Z" + ); + assert_eq!( + parse_timestamp_ms(&format_iso(1_767_225_600_120)).ok(), + Some(1_767_225_600_120) + ); } #[tokio::test] diff --git a/apps/server/src/diagnostics/host_metrics/volumes.rs b/apps/server/src/diagnostics/host_metrics/volumes.rs index 65fff9641c..dbaa640a4f 100644 --- a/apps/server/src/diagnostics/host_metrics/volumes.rs +++ b/apps/server/src/diagnostics/host_metrics/volumes.rs @@ -90,10 +90,12 @@ fn is_system_memory_mount(descriptor: &VolumeDescriptor) -> bool { .any(|root| descriptor.mount_point.starts_with(root)) } -/// The running AppImage's own image: a read-only FUSE mount holding this executable that is the -/// AppImage's by identity, from the listing alone. The runtime names the file system -/// `fuse.` and mounts it at `.mount_*`; either marks it, so an ordinary FUSE -/// volume holding the executable stays shown. `APPIMAGE` can be inherited from another image, but +/// An AppImage's image, from the listing alone. The runtime mounts every image read-only over +/// FUSE at `.mount_*`, so such a mount is hidden whether this process or another instance (or a +/// killed one that left it) runs from it. Cost: a user's own read-only FUSE volume mounted under a +/// `.mount_` directory is hidden too. The runtime also names the file system +/// `fuse.`; that marker alone hides a mount only when it holds this executable, +/// so an ordinary FUSE volume stays shown. `APPIMAGE` can be inherited from another image, but /// then it names a file system this mount does not have. `APPDIR` is not used for the same reason. /// Never `/`, which holds every executable: a read-only FUSE root is still the root volume. fn is_running_image( @@ -109,12 +111,12 @@ fn is_running_image( .mount_point .file_name() .is_some_and(|name| name.to_string_lossy().starts_with(".mount_")); + let holds_executable = running_executable + .is_some_and(|executable| executable.starts_with(&descriptor.mount_point)); descriptor.mount_point != Path::new("/") && (file_system == "fuse" || file_system.starts_with("fuse.")) && descriptor.read_only - && (named_after_image || runtime_mount) - && running_executable - .is_some_and(|executable| executable.starts_with(&descriptor.mount_point)) + && (runtime_mount || (named_after_image && holds_executable)) } /// The AppImage file the runtime started this process from (`APPIMAGE`). @@ -594,9 +596,9 @@ mod tests { ); } - /// #292: the AppImage's own mount (read-only FUSE, 0 B free) holds the running executable, - /// which decides it without reading the volume. Another app's FUSE mount, a writable FUSE - /// mount holding the executable, and the mounts above the image stay shown. + /// #292, #353: the AppImage's own mount (read-only FUSE, 0 B free) and another image's + /// `.mount_` mount are hidden without reading the volume. A writable FUSE mount holding the + /// executable and the mounts above the images stay shown. #[test] fn the_running_app_image_mount_is_hidden_before_it_is_read() { let read_only = |mount_point: &str, file_system: &str| { @@ -618,8 +620,12 @@ mod tests { reason_of(&hidden, "/tmp/.mount_BiBCodX"), &HiddenReason::System ); - assert_eq!(hidden.len(), 1, "{hidden:?}"); - assert_eq!(kept.len(), 3); + assert_eq!( + reason_of(&hidden, "/tmp/.mount_OtherY"), + &HiddenReason::System + ); + assert_eq!(hidden.len(), 2, "{hidden:?}"); + assert_eq!(kept.len(), 2); let (_, hidden) = pre_filter( vec![descriptor("/home", "fuse.mergerfs")], @@ -635,7 +641,67 @@ mod tests { None, None, ); - assert!(hidden.is_empty(), "without the executable nothing matches"); + assert_eq!( + hidden.len(), + 1, + "a `.mount_` mount is hidden without a known executable" + ); + } + + /// #353: every read-only FUSE mount under `.mount_` is some AppImage's runtime mount, so it is + /// hidden whether or not this process runs from it. The file system name alone still needs + /// the executable under the mount. + #[test] + fn another_app_image_mount_is_hidden_whether_or_not_this_process_runs_from_it() { + let read_only = |mount_point: &str, file_system: &str| { + let mut volume = descriptor(mount_point, file_system); + volume.read_only = true; + volume + }; + let elsewhere = Some(Path::new("/home/test-user/bin/bibcode")); + + let (_, hidden) = pre_filter( + vec![read_only("/tmp/.mount_OtherY", "fuse.BiBCode-1.0.AppImage")], + elsewhere, + None, + ); + assert_eq!(hidden.len(), 1, "(a) {hidden:?}"); + assert_eq!(hidden[0].reason, HiddenReason::System, "(a)"); + + let (_, hidden) = pre_filter(vec![read_only("/tmp/.mount_OtherY", "fuse")], None, None); + assert_eq!(hidden.len(), 1, "(b) {hidden:?}"); + assert_eq!(hidden[0].reason, HiddenReason::System, "(b)"); + + let (_, hidden) = pre_filter( + vec![read_only("/mnt/tools", "fuse.gocryptfs")], + elsewhere, + None, + ); + assert!(hidden.is_empty(), "(c) {hidden:?}"); + + let (_, hidden) = pre_filter( + vec![descriptor("/tmp/.mount_OtherY", "fuse.Other.AppImage")], + elsewhere, + None, + ); + assert!(hidden.is_empty(), "(d) a writable mount: {hidden:?}"); + + let (_, hidden) = pre_filter( + vec![read_only("/", "fuse.BiBCode-1.0.AppImage")], + Some(Path::new("/usr/bin/bibcode")), + None, + ); + assert!(hidden.is_empty(), "(e) the root: {hidden:?}"); + + let (_, hidden) = pre_filter( + vec![read_only("/opt/image", "fuse.BiBCode-1.0.AppImage")], + elsewhere, + Some(Path::new("/home/test-user/Apps/BiBCode-1.0.AppImage")), + ); + assert!( + hidden.is_empty(), + "(f) the name marker needs the executable: {hidden:?}" + ); } /// #292 review: a read-only FUSE root holds the executable too, and is still the root volume. diff --git a/apps/server/src/diagnostics/profiler/client.rs b/apps/server/src/diagnostics/profiler/client.rs index 56ddc95106..92378dbe24 100644 --- a/apps/server/src/diagnostics/profiler/client.rs +++ b/apps/server/src/diagnostics/profiler/client.rs @@ -25,6 +25,7 @@ use super::{ judge, }, record_finding, + redact::redact_message, store::EventId, }; use crate::rpc::ACTIVE_RPC_METHODS; @@ -32,6 +33,12 @@ use crate::rpc::ACTIVE_RPC_METHODS; const MAX_PAYLOAD_BYTES: usize = 64 * 1024; const MAX_RECORDS: usize = 64; const MAX_HISTOGRAMS: usize = 64; +const MAX_RENDER_ERRORS: usize = 8; +const MAX_COMPONENT_NAMES: usize = 12; +const MAX_NAME_BYTES: usize = 64; +const MAX_ROUTE_BYTES: usize = 256; +/// React's error codes are below 1000 today. +const MAX_REACT_ERROR_CODE: u32 = 9_999; const MAX_METHOD_BYTES: usize = 128; const MAX_VERSION_BYTES: usize = 64; const MAX_ID_BYTES: usize = 64; @@ -137,6 +144,21 @@ struct ClientBatch { /// the server's. Older clients send none. #[serde(default)] app_version: Option, + /// Render errors the client's error screen caught (R51). Older clients send none. + #[serde(default)] + render_errors: Vec, +} + +/// Names and codes only. Unknown fields, such as a message a client should never send, are +/// not deserialized, so they never reach an event. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RenderError { + error_type: String, + react_error_code: Option, + component_names: Vec, + route_pattern: Option, + fingerprint: String, } #[derive(Deserialize)] @@ -181,9 +203,12 @@ fn validate(batch: &Value) -> Result { .and_then(Value::as_array) .map_or(0, Vec::len) }; - if length("records") > MAX_RECORDS || length("histograms") > MAX_HISTOGRAMS { + if length("records") > MAX_RECORDS + || length("histograms") > MAX_HISTOGRAMS + || length("renderErrors") > MAX_RENDER_ERRORS + { return Err(too_large( - "The batch has more than 64 records or 64 histograms.", + "The batch has more than 64 records, 64 histograms or 8 render errors.", )); } let batch = ClientBatch::deserialize(batch).map_err(|error| invalid(&error.to_string()))?; @@ -295,10 +320,86 @@ impl ClientBatch { .as_ref() .is_none_or(|version| version.len() <= MAX_VERSION_BYTES), "appVersion is over 64 bytes.", - ) + )?; + for error in &self.render_errors { + ensure( + error.component_names.len() <= MAX_COMPONENT_NAMES, + "A render error has more than 12 component names.", + )?; + ensure( + error + .react_error_code + .is_none_or(|code| code <= MAX_REACT_ERROR_CODE) + && error.fingerprint.len() <= 16 + && !error.fingerprint.is_empty() + && error.fingerprint.bytes().all(|b| b.is_ascii_hexdigit()), + "A render error's code or fingerprint is invalid.", + )?; + } + Ok(()) } } +/// The error types a record may name; any other is [`OTHER_ERROR_TYPE`]. The web collector +/// applies the same set, identifier shape and placeholder (`clientCollector.ts`). +const ERROR_TYPES: [&str; 9] = [ + "Error", + "TypeError", + "RangeError", + "ReferenceError", + "SyntaxError", + "EvalError", + "URIError", + "AggregateError", + "DOMException", +]; +const OTHER_ERROR_TYPE: &str = "Other"; +const UNNAMED_COMPONENT: &str = "Unnamed"; +/// The first segments of the web app's route ids (`apps/web/src/routeTree.gen.ts`): a route +/// under any other is not the app's, so it is recorded as unknown. "" is the index route. +const ROUTE_ROOTS: [&str; 5] = ["", "_chat", "settings", "pair", "agents"]; + +/// A plain identifier: letters, digits, `_` and `$`, so never a path, a URL or a sentence. +fn is_identifier(value: &str) -> bool { + let mut chars = value.chars(); + value.len() <= MAX_NAME_BYTES + && chars + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_' || c == '$') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$') +} + +/// A route id of the web app, such as `/_chat/$environmentId/$threadId`: it starts at one of +/// [`ROUTE_ROOTS`], and each further segment is a `$parameter` or a lowercase word without +/// digits, so a real path's ids are refused. +fn is_route_pattern(value: &str) -> bool { + let word = |segment: &str| { + let segment = segment.strip_prefix('_').unwrap_or(segment); + segment + .chars() + .next() + .is_some_and(|c| c.is_ascii_lowercase()) + && segment.chars().all(|c| c.is_ascii_lowercase() || c == '-') + }; + let parameter = |segment: &str| { + segment + .strip_prefix('$') + .is_some_and(|name| name.is_empty() || is_identifier(name)) + }; + if value == "__root__" { + return true; + } + let Some(rest) = value.strip_prefix('/') else { + return false; + }; + let mut segments = rest.split('/'); + value.len() <= MAX_ROUTE_BYTES + && segments + .next() + .is_some_and(|root| ROUTE_ROOTS.contains(&root)) + && segments.all(|segment| segment.is_empty() || word(segment) || parameter(segment)) +} + struct Analysis { events: Vec<(&'static str, Value)>, /// Each finding with the index in `events` of the event that explains it. @@ -442,9 +543,78 @@ fn analyze( }; findings.push((finding, batch_event)); } + for error in &batch.render_errors { + let (payload, finding) = render_error(batch, error); + findings.push((finding, events.len())); + events.push(("client.render-error", payload)); + } Analysis { events, findings } } +/// One caught render error's event and finding. The server enforces the shapes again: an +/// error type outside the closed set becomes "Other", a component name that is not a plain +/// identifier becomes "Unnamed", and a route that is not one of the app's route ids becomes +/// `null`; what is left still passes the redaction helper, as every profiler record does. +/// The finding groups by type, code, route and component names: the packaged app's +/// component names are minified, so the route keeps different crashes apart. The client's +/// hash is shown, not trusted. +fn render_error(batch: &ClientBatch, error: &RenderError) -> (Value, FindingInput) { + let error_type = redact_message(if ERROR_TYPES.contains(&error.error_type.as_str()) { + &error.error_type + } else { + OTHER_ERROR_TYPE + }); + let names: Vec = error + .component_names + .iter() + .map(|name| { + redact_message(if is_identifier(name) { + name + } else { + UNNAMED_COMPONENT + }) + }) + .collect(); + let route = error + .route_pattern + .as_deref() + .filter(|route| is_route_pattern(route)) + .map(redact_message); + let payload = json!({ + "clientId": batch.client_id, + "errorType": error_type, + "reactErrorCode": error.react_error_code, + "componentNames": names, + "routePattern": route, + "fingerprint": redact_message(&error.fingerprint), + }); + let code = error + .react_error_code + .map_or_else(String::new, |code| code.to_string()); + let react = error + .react_error_code + .map_or_else(String::new, |code| format!(" (React error #{code})")); + let fingerprint_fields = [error_type.clone(), code, route.clone().unwrap_or_default()] + .into_iter() + .chain(names.iter().cloned()) + .collect(); + let finding = FindingInput { + kind: "client.render-error", + severity: Severity::Error, + source: SOURCE.to_owned(), + fingerprint_fields, + title: format!("The error screen caught {error_type}{react}"), + summary: format!( + "The app showed \"Something went wrong.\" for {error_type}{react} in {component} \ + on {route}.", + component = names.first().map_or("an unnamed component", String::as_str), + route = route.as_deref().unwrap_or("an unknown route"), + ), + evidence: payload.clone(), + }; + (payload, finding) +} + /// Where one request's time went, as the client saw it until the response was painted. struct RequestTiming { server_ms: Option, @@ -880,6 +1050,218 @@ mod tests { ); } + fn render_error() -> Value { + json!({ + "errorType": "Error", + "reactErrorCode": 185, + "componentNames": ["GitManagerBranchDropdown", "Popover"], + "routePattern": "/_chat/$environmentId/$threadId", + "fingerprint": "0a1b2c3d", + }) + } + + /// R51: each caught render error is one event; repeats share one finding, grouped by the + /// error's type, code and component names, never by the route or the client's hash. + #[test] + fn render_errors_become_events_and_one_grouped_finding() { + let book = LatencyBook::default(); + let mut report = batch(Vec::new()); + report["renderErrors"] = json!([render_error(), render_error()]); + let analysis = analyze_with(&book, &report); + + let events = events(&analysis, "client.render-error"); + assert_eq!(events.len(), 2); + assert_eq!( + events[0], + &json!({ + "clientId": "c1", + "errorType": "Error", + "reactErrorCode": 185, + "componentNames": ["GitManagerBranchDropdown", "Popover"], + "routePattern": "/_chat/$environmentId/$threadId", + "fingerprint": "0a1b2c3d", + }), + "no envId, message, URL or path" + ); + let findings: Vec<&(FindingInput, usize)> = analysis + .findings + .iter() + .filter(|(finding, _)| finding.kind == "client.render-error") + .collect(); + assert_eq!( + findings.len(), + 2, + "one per occurrence, merged by fingerprint" + ); + let (finding, event) = findings[0]; + assert_eq!(finding.severity, Severity::Error); + assert_eq!(finding.source, "client"); + assert_eq!( + finding.fingerprint_fields, + [ + "Error", + "185", + "/_chat/$environmentId/$threadId", + "GitManagerBranchDropdown", + "Popover" + ] + ); + assert_eq!(findings[1].0.fingerprint_fields, finding.fingerprint_fields); + assert_eq!(analysis.events[*event].0, "client.render-error"); + + let mut other_hash = render_error(); + other_hash["fingerprint"] = json!("ffffffff"); + report["renderErrors"] = json!([other_hash]); + let analysis = analyze_with(&book, &report); + assert_eq!( + analysis.findings[0].0.fingerprint_fields, finding.fingerprint_fields, + "the client's hash does not split the finding" + ); + // Minified names are shared by unrelated crashes: the route keeps them apart. + let mut other_route = render_error(); + other_route["routePattern"] = json!("/settings/general"); + report["renderErrors"] = json!([other_route]); + let analysis = analyze_with(&book, &report); + assert_ne!( + analysis.findings[0].0.fingerprint_fields, + finding.fingerprint_fields + ); + } + + /// Codex's adversarial shapes: content where a name or a route should be is replaced, + /// never recorded, in the event, the evidence and the grouping. + #[test] + fn content_shaped_render_error_fields_are_replaced() { + let book = LatencyBook::default(); + let cases = [ + ("errorType", json!("private_document.txt"), "Other"), + ("errorType", json!("PrivateDocumentError"), "Other"), + ("errorType", json!("Error: secret thread"), "Other"), + ]; + for (field, value, expected) in cases { + let mut error = render_error(); + error[field] = value.clone(); + let mut report = batch(Vec::new()); + report["renderErrors"] = json!([error]); + let analysis = analyze_with(&book, &report); + assert_eq!(analysis.events[1].1[field], expected, "{value}"); + assert_eq!(analysis.findings[0].0.fingerprint_fields[0], expected); + } + let mut error = render_error(); + error["componentNames"] = json!([ + "private prompt words", + "/home/test-user/app.tsx", + "https://example.test/app.js:1:2", + "Context.Provider", + "\"quoted\"", + "Panel", + ]); + let mut report = batch(Vec::new()); + report["renderErrors"] = json!([error]); + let analysis = analyze_with(&book, &report); + assert_eq!( + analysis.events[1].1["componentNames"], + json!([ + "Unnamed", "Unnamed", "Unnamed", "Unnamed", "Unnamed", "Panel" + ]) + ); + for route in [ + "/home/test-user/private-project", + "/_chat/env-1/thread-42", + "/settings?token=x", + "private-project", + ] { + let mut error = render_error(); + error["routePattern"] = json!(route); + report["renderErrors"] = json!([error]); + let analysis = analyze_with(&book, &report); + assert_eq!(analysis.events[1].1["routePattern"], Value::Null, "{route}"); + assert_eq!(analysis.findings[0].0.fingerprint_fields[2], "", "{route}"); + let finding = &analysis.findings[0].0; + let text = format!( + "{:?} {} {} {}", + finding.fingerprint_fields, finding.evidence, finding.title, finding.summary + ); + assert!( + !text.contains("private") && !text.contains("thread-42"), + "{text}" + ); + } + for route in [ + "/", + "__root__", + "/settings/source-control", + "/pair", + "/_chat/", + ] { + let mut error = render_error(); + error["routePattern"] = json!(route); + report["renderErrors"] = json!([error]); + let analysis = analyze_with(&book, &report); + assert_eq!(analysis.events[1].1["routePattern"], route); + } + } + + #[test] + fn a_render_error_keeps_only_its_known_fields() { + let book = LatencyBook::default(); + let mut leaky = render_error(); + leaky["message"] = json!("Cannot read secret-thread from ~/project/file.ts"); + leaky["url"] = json!("https://example.test/app"); + leaky["threadId"] = json!("thread-1"); + let mut report = batch(Vec::new()); + report["renderErrors"] = json!([leaky]); + let analysis = analyze_with(&book, &report); + let event = events(&analysis, "client.render-error")[0].to_string(); + for forbidden in ["message", "secret", "url", "https", "thread-1", "file.ts"] { + assert!(!event.contains(forbidden), "{forbidden} in {event}"); + } + } + + #[test] + fn malformed_render_errors_are_rejected() { + type Mutation = Box; + let cases: Vec<(&str, Mutation)> = vec![ + ( + "13 component names", + Box::new(|e| e["componentNames"] = json!(vec!["Panel"; 13])), + ), + ( + "a free-text fingerprint", + Box::new(|e| e["fingerprint"] = json!("secret thread")), + ), + ( + "a code out of range", + Box::new(|e| e["reactErrorCode"] = json!(100_000)), + ), + ]; + for (name, mutate) in cases { + let mut error = render_error(); + mutate(&mut error); + let mut report = batch(Vec::new()); + report["renderErrors"] = json!([error]); + let rejected = validate(&report) + .err() + .unwrap_or_else(|| panic!("{name} accepted")); + assert_eq!(tag(&rejected), "ProfilerClientBatchInvalid", "{name}"); + } + let mut report = batch(Vec::new()); + report["renderErrors"] = json!(vec![render_error(); 9]); + assert_eq!( + tag(&validate(&report).err().expect("rejected")), + "ProfilerClientBatchTooLarge" + ); + let mut root = render_error(); + root["routePattern"] = json!("__root__"); + root["reactErrorCode"] = Value::Null; + report["renderErrors"] = json!([root]); + assert!(validate(&report).is_ok(), "the root route and no code"); + assert!( + validate(&batch(Vec::new())).is_ok(), + "an older client sends no renderErrors" + ); + } + #[test] fn rejections_have_the_shape_of_the_contract_errors() { let fixtures = [ diff --git a/apps/server/src/diagnostics/profiler/worktree_audit.rs b/apps/server/src/diagnostics/profiler/worktree_audit.rs index 7e16df43eb..deb74d4661 100644 --- a/apps/server/src/diagnostics/profiler/worktree_audit.rs +++ b/apps/server/src/diagnostics/profiler/worktree_audit.rs @@ -489,6 +489,15 @@ const REMOVAL_MARKERS: [&str; 2] = [".bibcode-removal-identity", ".bibcode-remov /// The first kind of evidence `dir` has: the database's first, then one `stat` or one small /// read per marker at its root. Nothing below the root is read. +/// +/// shortcut: a creation record has no folder identity, so it can outlive its worktree when +/// another folder takes the same path, and that folder is then reported with `CreationRecord` +/// evidence; upgrade only if that warning proves noisy, by storing an identity at creation (a +/// schema change, D10). +/// +/// shortcut: a worktree whose record write never happened (a crash between Git's success and +/// the write) has no record; it is still recognized by its `.git` pointer file +/// (`GitPointerFile`). fn worktree_evidence( dir: &Path, recorded: &HashSet, @@ -512,6 +521,11 @@ fn worktree_evidence( /// A regular file, not a link, that starts with `gitdir:` as a worktree's `.git` file does. /// The open neither follows a link nor waits on a FIFO, and the opened file is the one checked, /// so a `.git` swapped after the folder was listed cannot block or redirect the audit. +/// +/// shortcut: the file is opened first and checked through the opened handle, so there is no +/// check-then-open gap, but `O_NOFOLLOW` and `FILE_FLAG_OPEN_REPARSE_POINT` protect only the +/// last path component: a swapped parent directory is not contained. Upgrade by reading +/// through `workspace/beneath.rs` `Dir` if the audit ever stops being advisory (D10). fn opens_as_git_pointer_file(path: &Path) -> bool { use std::io::Read; @@ -660,9 +674,12 @@ async fn compare( /// a worktree created at that path since keeps its own record. async fn reap_gone_records(repositories: &Repositories, gone: Vec) { for record in gone { - match tokio::fs::symlink_metadata(&record.worktree_path).await { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - _ => continue, + let path = record.worktree_path.clone(); + if !tokio::task::spawn_blocking(move || record_folder_is_gone(&path)) + .await + .unwrap_or(false) + { + continue; } if let Err(error) = repositories .reap_worktree_creation_record(record.record_id) @@ -673,6 +690,18 @@ async fn reap_gone_records(repositories: &Repositories, gone: Vec bool { + let path = Path::new(worktree_path); + matches!(std::fs::symlink_metadata(path), Err(error) if error.kind() == std::io::ErrorKind::NotFound) + && path + .parent() + .is_some_and(|parent| std::fs::metadata(parent).is_ok_and(|meta| meta.is_dir())) +} + /// Each project's managed directory in its physical spelling, and what one level of each /// holds: directories only, never following a link, at most `MAX_DIR_ENTRIES` read. fn list_managed( @@ -728,13 +757,10 @@ fn resolve( .into_iter() .take_while(|_| !cancel.is_cancelled()) { - match std::fs::symlink_metadata(&record.worktree_path) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - comparison.gone_records.push(record); - } - _ => { - recorded.insert(physical(Path::new(&record.worktree_path))); - } + if record_folder_is_gone(&record.worktree_path) { + comparison.gone_records.push(record); + } else { + recorded.insert(physical(Path::new(&record.worktree_path))); } } let incomplete: HashSet = evidence @@ -1382,6 +1408,59 @@ mod tests { assert_eq!(recorded_paths(&repositories).await, [written]); } + #[tokio::test] + async fn a_record_under_a_missing_parent_is_kept() { + let (_root, managed) = managed_dir(); + let repositories = migrated_repositories().await; + // An unmounted volume: the folder and its parent are both missing. + let path = managed.join("gone/wt").to_string_lossy().into_owned(); + repositories + .record_worktree_creation("/repo", &path) + .await + .unwrap(); + assert!(gone_after_read(&repositories, &managed).await.is_empty()); + let recorded = repositories.list_worktree_creation_records().await.unwrap(); + reap_gone_records(&repositories, recorded).await; + assert_eq!(recorded_paths(&repositories).await, [path]); + } + + #[tokio::test] + async fn a_record_whose_folder_is_missing_under_an_existing_parent_is_reaped() { + let (_root, managed) = managed_dir(); + let repositories = migrated_repositories().await; + let path = managed.join("wt").to_string_lossy().into_owned(); + repositories + .record_worktree_creation("/repo", &path) + .await + .unwrap(); + let gone = gone_after_read(&repositories, &managed).await; + assert_eq!(gone.len(), 1, "the parent exists and the folder does not"); + reap_gone_records(&repositories, gone).await; + assert!(recorded_paths(&repositories).await.is_empty()); + } + + #[tokio::test] + async fn a_record_is_kept_when_its_parent_vanishes_before_the_reap() { + let (_root, managed) = managed_dir(); + let repositories = migrated_repositories().await; + let parent = managed.join("volume"); + std::fs::create_dir(&parent).unwrap(); + let path = parent.join("wt").to_string_lossy().into_owned(); + repositories + .record_worktree_creation("/repo", &path) + .await + .unwrap(); + let gone = gone_after_read(&repositories, &managed).await; + assert_eq!(gone.len(), 1); + std::fs::remove_dir(&parent).unwrap(); + reap_gone_records(&repositories, gone).await; + assert_eq!( + recorded_paths(&repositories).await, + [path], + "the re-check keeps it" + ); + } + #[test] fn a_folder_without_worktree_evidence_is_not_an_orphan() { let (_root, managed) = managed_dir(); diff --git a/apps/server/src/git/broadcaster.rs b/apps/server/src/git/broadcaster.rs index 667d7b00e9..e48a61b185 100644 --- a/apps/server/src/git/broadcaster.rs +++ b/apps/server/src/git/broadcaster.rs @@ -15,6 +15,10 @@ use tokio_util::{sync::CancellationToken, task::TaskTracker}; #[cfg(test)] use super::watcher::test_timeout; +/// How old the last successful lookup must be before a failed one stops showing the +/// remembered pull request (D7). +pub(crate) const PULL_REQUEST_STALE_AFTER: Duration = Duration::from_secs(300); + use super::{ ChangeRequest, GitCommandError, GitRepository, GitWatchError, GitWatchRequest, GitWatchService, GitWatcherHealth, VcsStatusLocalResult, VcsStatusRemoteResult, VcsStatusResult, @@ -36,6 +40,16 @@ pub struct StatusBroadcaster { inner: Arc, } +/// A handle that does not keep the broadcaster alive, for callbacks it stores itself. +#[derive(Clone)] +pub(crate) struct WeakStatusBroadcaster(std::sync::Weak); + +impl WeakStatusBroadcaster { + pub(crate) fn upgrade(&self) -> Option { + self.0.upgrade().map(|inner| StatusBroadcaster { inner }) + } +} + struct Inner { repository: Arc, local_status_refresh_interval: Duration, @@ -100,6 +114,8 @@ struct RepositoryState { /// The next remote read publishes even when unchanged: a pull request changed, which only /// a published remote makes the status streams look up again. republish_remote: bool, + /// When a lookup last succeeded for the current branch, including "none open". + pr_recorded_at: Option, /// Ends the work run for the status subscribers once the last one leaves, even while a /// Git Manager signal subscriber keeps the entry. status_lookups: CancellationToken, @@ -211,6 +227,10 @@ fn log_signature_failure(error: &GitCommandError) { } impl StatusBroadcaster { + pub(crate) fn downgrade(&self) -> WeakStatusBroadcaster { + WeakStatusBroadcaster(Arc::downgrade(&self.inner)) + } + #[must_use] pub fn new( repository: Arc, @@ -516,6 +536,7 @@ impl StatusBroadcaster { remote_ref_name: None, pending_local_reconcile: false, republish_remote: false, + pr_recorded_at: None, status_lookups: CancellationToken::new(), remote_refresh_requests, git_manager_signature: None, @@ -935,6 +956,11 @@ impl StatusBroadcaster { /// Runs `task` for the repository's status subscribers, until the last one leaves or the /// broadcaster shuts down. With no status subscriber, `task` is dropped at once. + /// + /// shortcut: with no status subscriber the late lookup is dropped (D8). Nothing would + /// show its answer, and the next refresh asks again. A refresh with no subscriber + /// therefore never warms the remembered request. Upgrade only if a caller needs the + /// answer without a stream. pub(crate) fn spawn_for_subscribers( &self, cwd: &Path, @@ -971,17 +997,71 @@ impl StatusBroadcaster { pull_request: Option, ) { let mut state = self.lock_state(); - if let Some(remote) = state + if let Some(entry) = state .repositories .get_mut(cwd) .filter(|entry| entry.local.ref_name.as_deref() == ref_name) - .and_then(|entry| entry.remote.as_mut()) - .and_then(Option::as_mut) { - remote.pr = pull_request; + entry.pr_recorded_at = Some(tokio::time::Instant::now()); + if let Some(remote) = entry.remote.as_mut().and_then(Option::as_mut) { + remote.pr = pull_request; + } } } + /// A lookup for `ref_name` failed. When the last success for that branch is older than + /// [`PULL_REQUEST_STALE_AFTER`], the remembered request stops being shown (D7) and this + /// returns `true`; otherwise, after shutdown, for a former branch, or with nothing + /// remembered, nothing changes. It publishes nothing; callers decide. + /// + /// Callers pass only failures, never cancellations. The status stream counts its + /// `Ok(None)` only while its lookup's own token is not cancelled. The refresh's inner + /// lookup runs under a token nothing cancels, so its `None` is a failure, while the refresh + /// RPC's own cancellation is not the lookup's result. The late lookup a refresh hands to + /// subscribers ends `None` after shutdown, which this refuses because the broadcaster is + /// closed. + pub(crate) fn note_failed_pull_request_lookup( + &self, + cwd: &Path, + ref_name: Option<&str>, + ) -> bool { + let mut state = self.lock_state(); + if state.closed { + return false; + } + let Some(entry) = state + .repositories + .get_mut(cwd) + .filter(|entry| entry.local.ref_name.as_deref() == ref_name) + else { + return false; + }; + let stale = entry + .pr_recorded_at + .is_some_and(|recorded| recorded.elapsed() >= PULL_REQUEST_STALE_AFTER); + let Some(remote) = entry.remote.as_mut().and_then(Option::as_mut) else { + return false; + }; + if !stale || remote.pr.is_none() { + return false; + } + remote.pr = None; + true + } + + /// Moves the last success for `cwd` back by `age`, for stream tests on the real clock. + #[cfg(all(test, unix))] + pub(crate) fn age_pull_request_for_test(&self, cwd: &Path, age: Duration) { + let mut state = self.lock_state(); + let entry = state.repositories.get_mut(cwd).expect("a repository entry"); + let recorded = entry.pr_recorded_at.expect("a recorded pull request"); + entry.pr_recorded_at = Some( + recorded + .checked_sub(age) + .expect("the machine has been up longer than the age"), + ); + } + /// Registers the observer that runs after a finished mutation or a /// reported local change (`notify_local_change`). Status reads, including /// watcher-driven reads, also reach it when repository availability changes. @@ -2344,6 +2424,7 @@ fn update_git_manager_signature( fn clear_remote_for_ref_change(entry: &mut RepositoryState) { entry.remote = None; + entry.pr_recorded_at = None; entry.remote_fence = None; entry.remote_ref_name = None; entry.pending_local_reconcile = false; @@ -6733,6 +6814,94 @@ mod tests { broadcaster.shutdown().await; } + /// A broadcaster that remembers an open request for the branch `old` of `cwd`. + fn remembering_a_pull_request() -> ( + tempfile::TempDir, + PathBuf, + StatusBroadcaster, + mpsc::Receiver>, + ) { + let root = tempfile::tempdir().expect("repository folder"); + let cwd = fs::canonicalize(root.path()).expect("canonical folder"); + let broadcaster = StatusBroadcaster::new( + Arc::new(GitRepository::default()), + Duration::from_secs(3_600), + 4, + ); + let events = install_epoch_repository(&broadcaster, &cwd); + broadcaster.record_pull_request( + &cwd, + Some("old"), + Some(ChangeRequest { + number: 7, + title: "Feature".to_owned(), + url: "https://forge.example.test/pull/7".to_owned(), + base_ref: "main".to_owned(), + head_ref: "old".to_owned(), + state: "open".to_owned(), + }), + ); + (root, cwd, broadcaster, events) + } + + #[tokio::test(start_paused = true)] + async fn a_failed_lookup_clears_a_request_recorded_six_minutes_ago() { + let (_root, cwd, broadcaster, _events) = remembering_a_pull_request(); + tokio::time::advance(Duration::from_secs(360)).await; + assert!(broadcaster.note_failed_pull_request_lookup(&cwd, Some("old"))); + assert_eq!(broadcaster.known_pull_request(&cwd, Some("old")), None); + broadcaster.shutdown().await; + } + + #[tokio::test(start_paused = true)] + async fn a_failed_lookup_keeps_a_request_recorded_four_minutes_ago() { + let (_root, cwd, broadcaster, _events) = remembering_a_pull_request(); + tokio::time::advance(Duration::from_secs(240)).await; + assert!(!broadcaster.note_failed_pull_request_lookup(&cwd, Some("old"))); + assert!(broadcaster.known_pull_request(&cwd, Some("old")).is_some()); + broadcaster.shutdown().await; + } + + #[tokio::test(start_paused = true)] + async fn a_request_recorded_six_minutes_ago_stays_without_a_failed_lookup() { + let (_root, cwd, broadcaster, _events) = remembering_a_pull_request(); + tokio::time::advance(Duration::from_secs(360)).await; + assert!(broadcaster.known_pull_request(&cwd, Some("old")).is_some()); + broadcaster.shutdown().await; + } + + #[tokio::test(start_paused = true)] + async fn a_failed_lookup_for_a_former_branch_clears_nothing() { + let (_root, cwd, broadcaster, _events) = remembering_a_pull_request(); + tokio::time::advance(Duration::from_secs(360)).await; + assert!(!broadcaster.note_failed_pull_request_lookup(&cwd, Some("former"))); + assert!(broadcaster.known_pull_request(&cwd, Some("old")).is_some()); + broadcaster.shutdown().await; + } + + #[tokio::test(start_paused = true)] + async fn a_failed_lookup_after_a_ref_change_clears_nothing() { + let (_root, cwd, broadcaster, _events) = remembering_a_pull_request(); + clear_remote_for_ref_change( + broadcaster + .lock_state() + .repositories + .get_mut(&cwd) + .expect("repository entry"), + ); + tokio::time::advance(Duration::from_secs(360)).await; + assert!(!broadcaster.note_failed_pull_request_lookup(&cwd, Some("old"))); + broadcaster.shutdown().await; + } + + #[tokio::test(start_paused = true)] + async fn a_failed_lookup_after_shutdown_clears_nothing() { + let (_root, cwd, broadcaster, _events) = remembering_a_pull_request(); + tokio::time::advance(Duration::from_secs(360)).await; + broadcaster.shutdown().await; + assert!(!broadcaster.note_failed_pull_request_lookup(&cwd, Some("old"))); + } + #[tokio::test] async fn a_snapshot_under_a_new_epoch_keeps_the_known_pull_request() { let root = tempfile::tempdir().expect("repository folder"); @@ -7617,6 +7786,7 @@ mod tests { remote_ref_name: None, pending_local_reconcile: false, republish_remote: false, + pr_recorded_at: None, status_lookups: CancellationToken::new(), remote_refresh_requests, git_manager_signature: None, diff --git a/apps/server/src/git/process.rs b/apps/server/src/git/process.rs index ca138c93dc..94629f9843 100644 --- a/apps/server/src/git/process.rs +++ b/apps/server/src/git/process.rs @@ -579,6 +579,12 @@ impl WriteSettlement { // stop leaves one only when Git could not act on the request. Its lock is ours only if // the root still held it open just before the kill: a lock nobody of ours held is // another Git process's, even one that stays unchanged during the wait. + // + // R22: a write stopped by force while a hook runs holds no open lock, so on Linux and + // macOS a lock another Git process took meanwhile is kept, as + // `index_lock_cleanup_keeps_a_lock_another_git_took_during_a_forced_write` proves. The + // cost is that a lock the killed Git itself left during a hook also stays. On Windows the + // 2-second rule has no ownership check and can still remove a foreign lock. if cfg!(any(target_os = "linux", target_os = "macos")) && report.before_force != Some(true) { return; @@ -983,7 +989,12 @@ fn takes_index_lock(args: &[OsString]) -> bool { "checkout" | "switch" => checkout_takes_index_lock(&rest), "apply" => has("--cached"), "stash" => matches!(rest.first(), Some(&("push" | "apply" | "pop"))), - // `pull` holds no lock while it fetches, which is where it waits. + // shortcut: pull is unlisted on purpose (D5), for three reasons. Its merge child, not the + // root, holds `index.lock`. `holds_open` looks only at the root's process id on Linux and + // macOS, so a listed `pull` could never pass the ownership check there. On Windows a + // listed `pull` would arm the 2-second rule during the fetch, which holds no lock, and + // could remove a foreign lock. Upgrade when holds_open covers the process group and + // Windows can check ownership. _ => false, } } diff --git a/apps/server/src/git/sign_in.rs b/apps/server/src/git/sign_in.rs index 65ee4c4cc6..e0ad9f2f6e 100644 --- a/apps/server/src/git/sign_in.rs +++ b/apps/server/src/git/sign_in.rs @@ -97,11 +97,21 @@ pub fn has_gcm_refusal(stderr: &str) -> bool { }) } -/// True when Git stopped for a missing username with prompts disabled (#343). +/// True when Git stopped for a missing username with prompts disabled (#343): a line that +/// starts with `fatal:` holds both phrases, so a commit message or hook output that quotes +/// them does not count (R23). +/// +/// Neither caller joins lines: Git Manager passes the last Git output's raw stderr, and +/// Source Control the wire `detail`, which is that stderr trimmed and redacted. Git's progress +/// output separates updates with `\r`, so the fatal line can follow one after `\r`, not `\n`. #[must_use] pub fn has_missing_username_refusal(detail: &str) -> bool { - let detail = detail.to_ascii_lowercase(); - detail.contains("could not read username") && detail.contains("terminal prompts disabled") + detail.split(['\n', '\r']).any(|line| { + let line = line.trim_start().to_ascii_lowercase(); + line.starts_with("fatal:") + && line.contains("could not read username") + && line.contains("terminal prompts disabled") + }) } /// Where this server's Git runs, as a failed push names it (#343): `remote` for every server @@ -587,6 +597,35 @@ mod tests { )); } + const HOOK_ECHO: &str = "remote: checking\nhook: could not read username, terminal prompts disabled\nTo https://gitlab.example.test/example-group/sample-project.git"; + const AFTER_PROGRESS: &str = "Writing objects: 50%\rfatal: could not read Username for 'https://gitlab.example.test': terminal prompts disabled\n"; + + #[test] + fn the_missing_username_refusal_needs_a_fatal_line() { + assert!(!has_missing_username_refusal( + "fix: could not read Username and terminal prompts disabled" + )); + assert!(!has_missing_username_refusal(HOOK_ECHO)); + assert!(has_missing_username_refusal(AFTER_PROGRESS)); + assert!(has_missing_username_refusal( + " FATAL: Could not read Username for 'https://gitlab.example.test': Terminal Prompts Disabled\r\n" + )); + assert!(!has_missing_username_refusal( + "fatal: could not read Username for 'https://gitlab.example.test'\nterminal prompts disabled" + )); + assert!(has_missing_username_refusal(MISSING_USERNAME)); + } + + #[test] + fn push_guidance_ignores_both_phrases_outside_a_fatal_line() { + let place = ExecutionPlace { + remote: true, + server_name: None, + }; + assert_eq!(place.push_guidance(HOOK_ECHO), None); + assert!(place.push_guidance(AFTER_PROGRESS).is_some()); + } + #[test] fn names_a_known_server_and_keeps_gits_line_after_a_blank_line() { assert_eq!( diff --git a/apps/server/src/lifecycle.rs b/apps/server/src/lifecycle.rs index 745607e3ff..06f80dfb50 100644 --- a/apps/server/src/lifecycle.rs +++ b/apps/server/src/lifecycle.rs @@ -70,6 +70,7 @@ fn connect_environment_descriptor(config: &ServerConfig) -> serde_json::Value { "terminalOrderedInput": true, "terminalSizeOwnership": true, "terminalUserInputClaim": true, + "terminalSizeHandOver": true, "terminalImagePaste": true, "pullRequestCreateOptions": true, "vcsCloneReattach": true, @@ -1347,6 +1348,7 @@ mod tests { assert_eq!(descriptor["capabilities"]["terminalOrderedInput"], true); assert_eq!(descriptor["capabilities"]["terminalSizeOwnership"], true); assert_eq!(descriptor["capabilities"]["terminalUserInputClaim"], true); + assert_eq!(descriptor["capabilities"]["terminalSizeHandOver"], true); assert_eq!(descriptor["capabilities"]["previewTunnels"], true); assert_eq!(descriptor["capabilities"]["terminalImagePaste"], true); assert_eq!(descriptor["capabilities"]["pullRequestCreateOptions"], true); diff --git a/apps/server/src/mcp/activity.rs b/apps/server/src/mcp/activity.rs index cd8d48621f..22e95d6f91 100644 --- a/apps/server/src/mcp/activity.rs +++ b/apps/server/src/mcp/activity.rs @@ -58,9 +58,9 @@ const MOST_ID_CHARS: usize = 128; /// Rows older than this instant are past the 7-day retention. fn retention_cutoff() -> String { - (time::OffsetDateTime::now_utc() - time::Duration::days(KEPT_DAYS)) - .format(&time::format_description::well_known::Rfc3339) - .unwrap_or_default() + crate::persistence::format_timestamp( + time::OffsetDateTime::now_utc() - time::Duration::days(KEPT_DAYS), + ) } /// The actor of a command sent with `context`'s credential: an outside agent's session, with diff --git a/apps/server/src/mcp/control.rs b/apps/server/src/mcp/control.rs index 91a30742df..7e2ca5e03c 100644 --- a/apps/server/src/mcp/control.rs +++ b/apps/server/src/mcp/control.rs @@ -1148,9 +1148,7 @@ pub(crate) fn storage_failure(error: impl std::fmt::Display) -> Value { } pub(crate) fn now_iso() -> String { - time::OffsetDateTime::now_utc() - .format(&time::format_description::well_known::Rfc3339) - .unwrap_or_default() + crate::persistence::now_timestamp() } impl ToolCall<'_> { @@ -1398,9 +1396,9 @@ pub(crate) fn idempotency_conflict() -> Value { /// Retry rows created before this instant are past their 24-hour window. pub(crate) fn request_row_cutoff() -> String { - (time::OffsetDateTime::now_utc() - time::Duration::seconds(REQUEST_ROW_TTL_SECONDS)) - .format(&time::format_description::well_known::Rfc3339) - .unwrap_or_default() + crate::persistence::format_timestamp( + time::OffsetDateTime::now_utc() - time::Duration::seconds(REQUEST_ROW_TTL_SECONDS), + ) } /// Deletes retry rows past their 24-hour window. diff --git a/apps/server/src/mcp/tools/worktrees.rs b/apps/server/src/mcp/tools/worktrees.rs index 73407b34ac..5d40c6fa78 100644 --- a/apps/server/src/mcp/tools/worktrees.rs +++ b/apps/server/src/mcp/tools/worktrees.rs @@ -4,7 +4,8 @@ //! They reach the worktree catalog's own handlers, so a removal records its profiler outcome //! there (`worktree_audit::record_removal_outcome`, once per real removal and never for a //! replay) and finishes in the catalog's operation runtime even when the MCP call is -//! cancelled. Creation and removal carry a `commandId`, so a retry with the same +//! cancelled. A failed creation's rollback records a `create-rollback` outcome there too. +//! Creation and removal carry a `commandId`, so a retry with the same //! `clientRequestId` replays the stored request by its receipt ([`ToolCall::commands`]). use std::{sync::OnceLock, time::Instant}; diff --git a/apps/server/src/orchestration/engine.rs b/apps/server/src/orchestration/engine.rs index e66d4945e5..4bdbb3e270 100644 --- a/apps/server/src/orchestration/engine.rs +++ b/apps/server/src/orchestration/engine.rs @@ -7884,9 +7884,7 @@ fn request_expiry_command( } fn now_rfc3339() -> String { - time::OffsetDateTime::now_utc() - .format(&time::format_description::well_known::Rfc3339) - .unwrap_or_default() + crate::persistence::now_timestamp() } fn request_expiry_summary(kind: &str) -> &'static str { @@ -15498,3 +15496,41 @@ mod tests { engine.shutdown().await; } } + +#[cfg(test)] +mod timestamp_tie_tests { + use super::*; + + /// #362: two assistant messages 500 microseconds apart in one millisecond, the later with + /// the lower id: the later one is still the turn's latest. + #[test] + fn the_later_assistant_message_in_one_millisecond_is_the_latest() { + let at = |micros: i128| { + crate::persistence::format_timestamp( + time::OffsetDateTime::from_unix_timestamp_nanos( + 1_767_225_600_120_000_000 + micros * 1_000, + ) + .expect("instant"), + ) + }; + let mut connection = rusqlite::Connection::open_in_memory().expect("in-memory SQLite"); + crate::persistence::run_migrations(&mut connection, None).expect("migrations apply"); + for (message_id, micros) in [("message-b-earlier", 100), ("message-a-later", 600)] { + connection + .execute( + "INSERT INTO projection_thread_messages (message_id, thread_id, turn_id, \ + role, text, is_streaming, created_at, updated_at) \ + VALUES (?1, 'thread-a', 'turn-a', 'assistant', '', 0, ?2, ?2)", + rusqlite::params![message_id, at(micros)], + ) + .expect("message row"); + } + let transaction = connection.transaction().expect("transaction"); + assert_eq!( + latest_assistant_message_id_tx(&transaction, "thread-a", "turn-a") + .expect("query runs") + .as_deref(), + Some("message-a-later") + ); + } +} diff --git a/apps/server/src/orchestration/engine/compaction.rs b/apps/server/src/orchestration/engine/compaction.rs index 2bd21c31ea..4b75027bd4 100644 --- a/apps/server/src/orchestration/engine/compaction.rs +++ b/apps/server/src/orchestration/engine/compaction.rs @@ -937,6 +937,13 @@ pub fn reading_needs_guard(reading: &Value) -> bool { /// Whether a pending start needs a guard compaction before it is claimed. Only once per /// delivery, never right after a compaction no ordinary turn has followed (its reading predates /// that compaction), and only from a reading of the conversation the delivery will continue. +/// Whether the thread's last completed compaction has no other turn that started after it. +/// The `>` compares stored timestamp text, so both sides must share one width. +const LAST_COMPACTION_WITHOUT_LATER_TURN_SQL: &str = "SELECT EXISTS( + SELECT 1 FROM (SELECT turn_id, updated_at FROM thread_compactions WHERE thread_id = ?1 AND state = 'completed' ORDER BY updated_at DESC LIMIT 1) AS last + WHERE NOT EXISTS(SELECT 1 FROM projection_turns AS turn WHERE turn.thread_id = ?1 AND turn.turn_id IS NOT NULL + AND turn.turn_id IS NOT last.turn_id AND COALESCE(turn.started_at, turn.requested_at) > last.updated_at))"; + fn guard_needed_on( connection: &Connection, thread_id: &str, @@ -960,14 +967,10 @@ fn guard_needed_on( let Some(conversation_id) = target.conversation_id else { return Ok(false); }; - let compacted_without_turn: bool = connection.query_row( - "SELECT EXISTS( - SELECT 1 FROM (SELECT turn_id, updated_at FROM thread_compactions WHERE thread_id = ?1 AND state = 'completed' ORDER BY updated_at DESC LIMIT 1) AS last - WHERE NOT EXISTS(SELECT 1 FROM projection_turns AS turn WHERE turn.thread_id = ?1 AND turn.turn_id IS NOT NULL - AND turn.turn_id IS NOT last.turn_id AND COALESCE(turn.started_at, turn.requested_at) > last.updated_at))", - [thread_id], - |row| row.get(0), - )?; + let compacted_without_turn: bool = + connection.query_row(LAST_COMPACTION_WITHOUT_LATER_TURN_SQL, [thread_id], |row| { + row.get(0) + })?; if compacted_without_turn { return Ok(false); } @@ -1133,3 +1136,54 @@ impl super::OrchestrationEngine { .await } } + +#[cfg(test)] +mod timestamp_tie_tests { + use super::*; + + /// #362: a turn that starts 500 microseconds after a compaction completed, in the same + /// millisecond, is a later turn, so the guard is still considered. + #[test] + fn a_turn_in_the_same_millisecond_after_a_compaction_counts_as_later() { + let at = |micros: i128| { + crate::persistence::format_timestamp( + time::OffsetDateTime::from_unix_timestamp_nanos( + 1_767_225_600_120_000_000 + micros * 1_000, + ) + .expect("instant"), + ) + }; + let mut connection = Connection::open_in_memory().expect("in-memory SQLite"); + crate::persistence::run_migrations(&mut connection, None).expect("migrations apply"); + connection + .execute( + "INSERT INTO thread_compactions (compaction_id, thread_id, trigger, state, \ + provider_instance_id, provider_kind, turn_id, created_at, updated_at) \ + VALUES ('compaction-a', 'thread-a', 'auto', 'completed', 'instance-a', \ + 'codex', 'turn-compacted', ?1, ?2)", + params![at(0), at(100)], + ) + .expect("compaction row"); + connection + .execute( + "INSERT INTO projection_turns (thread_id, turn_id, state, requested_at, \ + started_at, checkpoint_files_json) \ + VALUES ('thread-a', 'turn-next', 'running', ?1, ?2, '[]')", + params![at(50), at(600)], + ) + .expect("turn row"); + let compacted_without_turn: bool = connection + .query_row( + LAST_COMPACTION_WITHOUT_LATER_TURN_SQL, + ["thread-a"], + |row| row.get(0), + ) + .expect("query runs"); + assert!( + !compacted_without_turn, + "the turn started at {} after the compaction at {}", + at(600), + at(100) + ); + } +} diff --git a/apps/server/src/persistence/mod.rs b/apps/server/src/persistence/mod.rs index 75f216922f..858f626d43 100644 --- a/apps/server/src/persistence/mod.rs +++ b/apps/server/src/persistence/mod.rs @@ -5,6 +5,7 @@ mod migrations; mod repositories; mod state_files; mod store; +mod timestamp; pub use backup::{ BackupError, BackupInventory, BackupInventoryIssue, BackupManifest, BackupTrigger, @@ -31,3 +32,4 @@ pub use store::{ PreparedStore, StorageInstanceId, StoreClassification, StoreStartupError, ensure_no_interrupted_recovery, prepare_store, read_storage_instance_id, }; +pub use timestamp::{canonical_timestamp, format_timestamp, format_timestamp_ms, now_timestamp}; diff --git a/apps/server/src/persistence/repositories.rs b/apps/server/src/persistence/repositories.rs index 2bd02652c5..5c4c742f09 100644 --- a/apps/server/src/persistence/repositories.rs +++ b/apps/server/src/persistence/repositories.rs @@ -308,12 +308,12 @@ impl Repositories { } /// Records that BiBCode created a worktree at `worktree_path`. A creation at a path that - /// still has a record replaces it with a new `record_id`. + /// still has a record replaces it with a new `record_id`, which is returned. pub async fn record_worktree_creation( &self, project_cwd: &str, worktree_path: &str, - ) -> Result<()> { + ) -> Result { let (project_cwd, worktree_path) = (project_cwd.to_owned(), worktree_path.to_owned()); self.database .call(move |connection| { @@ -322,21 +322,25 @@ impl Repositories { VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", params![worktree_path, project_cwd], )?; - Ok(()) + Ok(connection.last_insert_rowid()) }) .await } - /// Deletes the creation record of a removed worktree, written under any project spelling. - pub async fn delete_worktree_creation_record(&self, worktree_path: &str) -> Result<()> { + /// The `record_id` of the current creation record at `worktree_path`, written under any + /// project spelling. + pub async fn worktree_creation_record_id(&self, worktree_path: &str) -> Result> { let worktree_path = worktree_path.to_owned(); self.database .call(move |connection| { - connection.execute( - "DELETE FROM worktree_creation_records WHERE worktree_path = ?", - [worktree_path], - )?; - Ok(()) + connection + .query_row( + "SELECT record_id FROM worktree_creation_records WHERE worktree_path = ?", + [worktree_path], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) }) .await } @@ -2217,7 +2221,7 @@ impl Repositories { { Some(sooner) } - (_, Some(pending)) => Some(format_epoch_ms(pending)), + (_, Some(pending)) => Some(crate::persistence::format_timestamp_ms(pending)), (next, None) => next, }; let mcp_named_clients = if table_has_named_clients(&transaction)? { @@ -4263,17 +4267,6 @@ fn source_class(source_key: &str) -> &str { source_key.split(':').next().unwrap_or_default() } -/// Epoch milliseconds as an RFC 3339 timestamp. -fn format_epoch_ms(epoch_ms: i64) -> String { - time::OffsetDateTime::from_unix_timestamp_nanos(i128::from(epoch_ms) * 1_000_000) - .ok() - .and_then(|date| { - date.format(&time::format_description::well_known::Rfc3339) - .ok() - }) - .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_owned()) -} - /// An RFC 3339 timestamp as epoch milliseconds; `None` when it does not parse. fn parse_epoch_ms(value: &str) -> Option { time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) @@ -4315,7 +4308,7 @@ fn prune_mcp_oauth_on( connection.execute( "DELETE FROM mcp_oauth_refresh_tokens WHERE session_id NOT IN \ (SELECT session_id FROM auth_sessions WHERE revoked_at IS NULL AND expires_at > ?)", - params![format_epoch_ms(now_ms)], + params![crate::persistence::format_timestamp_ms(now_ms)], )?; Ok(pending_gone) } @@ -5771,25 +5764,46 @@ mod worktree_creation_record_tests { } #[tokio::test] - async fn worktree_creation_record_delete_removes_only_that_path() { + async fn worktree_creation_record_id_names_the_current_record() { let repositories = migrated(Database::open_in_memory().await.unwrap()).await; - for (project, path) in [ - ("/repo/", "/worktrees/feature-a"), - ("/repo", "/worktrees/feature-b"), - ] { + let first = repositories + .record_worktree_creation("/repo", "/worktrees/feature-a") + .await + .unwrap(); + assert_eq!( repositories - .record_worktree_creation(project, path) + .worktree_creation_record_id("/worktrees/feature-a") .await - .unwrap(); - } - // The worktree path alone names the record, however the project root is spelled. + .unwrap(), + Some(first) + ); + let second = repositories + .record_worktree_creation("/repo", "/worktrees/feature-a") + .await + .unwrap(); + assert_ne!(first, second, "a recreation is a new record"); + assert_eq!( + repositories + .worktree_creation_record_id("/worktrees/feature-a") + .await + .unwrap(), + Some(second) + ); repositories - .delete_worktree_creation_record("/worktrees/feature-a") + .reap_worktree_creation_record(first) .await .unwrap(); assert_eq!( recorded_paths(&repositories).await, - ["/worktrees/feature-b"] + ["/worktrees/feature-a"], + "reaping the old id keeps the later record" + ); + assert_eq!( + repositories + .worktree_creation_record_id("/worktrees/other") + .await + .unwrap(), + None ); } @@ -6081,3 +6095,50 @@ mod frozen_session_unfreeze_tests { } } } + +#[cfg(test)] +mod timestamp_text_order_tests { + use super::*; + use crate::persistence::format_timestamp_ms; + + /// 2026-01-01T00:00:00Z, a whole second: the instant a trimmed fraction writes shortest. + const SECOND_MS: i64 = 1_767_225_600_000; + + /// #362: the cleanup keeps the refresh token of a session that expires one millisecond + /// after a cleanup run on a whole second. Fixed timestamps, so load cannot change it. + #[test] + fn the_oauth_cleanup_keeps_a_token_whose_session_outlives_it_by_a_millisecond() { + let mut connection = Connection::open_in_memory().expect("in-memory SQLite"); + crate::persistence::run_migrations(&mut connection, None).expect("migrations apply"); + connection + .execute( + "INSERT INTO auth_sessions (session_id, subject, scopes, method, issued_at, \ + expires_at) VALUES ('session-a', 'mcp-client', '[]', 'mcp-oauth', ?1, ?2)", + params![ + format_timestamp_ms(SECOND_MS - 60_000), + format_timestamp_ms(SECOND_MS + 1) + ], + ) + .expect("session row"); + connection + .execute( + "INSERT INTO mcp_oauth_refresh_tokens (token_hash, session_id, client_id, \ + issued_at) VALUES ('hash-a', 'session-a', 'client-a', ?1)", + params![SECOND_MS - 60_000], + ) + .expect("refresh token row"); + + prune_mcp_oauth_on(&connection, SECOND_MS, crate::auth::oauth::LIMITS) + .expect("the cleanup runs"); + + let kept: i64 = connection + .query_row("SELECT COUNT(*) FROM mcp_oauth_refresh_tokens", [], |row| { + row.get(0) + }) + .expect("count"); + assert_eq!( + kept, 1, + "the live session's refresh token was deleted early" + ); + } +} diff --git a/apps/server/src/persistence/timestamp.rs b/apps/server/src/persistence/timestamp.rs new file mode 100644 index 0000000000..a4e74f3b4c --- /dev/null +++ b/apps/server/src/persistence/timestamp.rs @@ -0,0 +1,110 @@ +//! The text form of timestamps the store compares as text. + +/// The fallback for an instant outside years 0 to 9999. +const EPOCH: &str = "1970-01-01T00:00:00.000000000Z"; + +/// The one text form of a timestamp the store compares as text (`expires_at > ?`, `MAX`, +/// `ORDER BY`): RFC 3339 in UTC with exactly nine fraction digits. Text order is time order only +/// at a fixed width; the `time` crate's `Rfc3339` trims the fraction, so `…:00Z` would sort after +/// `…:00.001Z` (#362). Nine digits keep a clock value's nanoseconds, so rows written within one +/// millisecond still order. Against a 3-digit value (SQLite's `strftime('%f')`, a client's +/// `toISOString`) the order is off by under a millisecond. +pub fn format_timestamp(at: time::OffsetDateTime) -> String { + let at = at.to_offset(time::UtcOffset::UTC); + if !(0..=9999).contains(&at.year()) { + return EPOCH.to_owned(); + } + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:09}Z", + at.year(), + u8::from(at.month()), + at.day(), + at.hour(), + at.minute(), + at.second(), + at.nanosecond() + ) +} + +/// Epoch milliseconds in the store's timestamp form; see [`format_timestamp`]. +pub fn format_timestamp_ms(epoch_ms: i64) -> String { + time::OffsetDateTime::from_unix_timestamp_nanos(i128::from(epoch_ms) * 1_000_000) + .map_or_else(|_| EPOCH.to_owned(), format_timestamp) +} + +/// RFC 3339 text from outside the server (a client, an imported transcript) in the store's +/// timestamp form; `None` when it does not parse. +pub fn canonical_timestamp(text: &str) -> Option { + time::OffsetDateTime::parse(text.trim(), &time::format_description::well_known::Rfc3339) + .ok() + .map(format_timestamp) +} + +/// Now in the store's timestamp form; see [`format_timestamp`]. +pub fn now_timestamp() -> String { + format_timestamp(time::OffsetDateTime::now_utc()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 2026-01-01T00:00:00Z, a whole second: the instant a trimmed fraction writes shortest. + const SECOND_MS: i64 = 1_767_225_600_000; + + /// Instants one microsecond apart, trailing zeros included, keep their order as text. + #[test] + fn instants_a_microsecond_apart_sort_as_text_in_time_order() { + let base = i128::from(SECOND_MS) * 1_000_000; + let texts: Vec = [0, 1_000, 2_000, 10_000, 100_000, 120_000, 121_000, 999_000] + .into_iter() + .map(|nanos| { + format_timestamp( + time::OffsetDateTime::from_unix_timestamp_nanos(base + nanos).expect("instant"), + ) + }) + .collect(); + for pair in texts.windows(2) { + assert!( + pair[0] < pair[1], + "{} must sort before {}", + pair[0], + pair[1] + ); + } + } + + /// Every store formatter writes one width, so text order is time order across them. + #[test] + fn store_timestamps_sort_as_text_in_time_order() { + let instants = + [0, 1, 9, 10, 100, 120, 121, 999, 1_000, 1_001].map(|offset| SECOND_MS + offset); + let texts: Vec = instants.iter().map(|ms| format_timestamp_ms(*ms)).collect(); + for pair in texts.windows(2) { + assert!( + pair[0] < pair[1], + "{} must sort before {}", + pair[0], + pair[1] + ); + } + let at = time::OffsetDateTime::from_unix_timestamp_nanos( + i128::from(SECOND_MS) * 1_000_000 + 120_456_789, + ) + .expect("instant"); + assert_eq!(format_timestamp(at), "2026-01-01T00:00:00.120456789Z"); + assert_eq!( + format_timestamp_ms(SECOND_MS + 120), + "2026-01-01T00:00:00.120000000Z" + ); + assert_eq!( + time::OffsetDateTime::parse( + &format_timestamp_ms(SECOND_MS + 120), + &time::format_description::well_known::Rfc3339 + ) + .ok() + .map(|at| at.unix_timestamp_nanos() / 1_000_000), + Some(i128::from(SECOND_MS + 120)) + ); + } +} diff --git a/apps/server/src/production/control.rs b/apps/server/src/production/control.rs index 482b453e49..a6665cc392 100644 --- a/apps/server/src/production/control.rs +++ b/apps/server/src/production/control.rs @@ -2907,6 +2907,7 @@ fn environment_descriptor(config: &ServerConfig, activity_protocol_registered: b }); // Outside the literal above, which is at `json!`'s recursion limit. capabilities["terminalUserInputClaim"] = json!(true); + capabilities["terminalSizeHandOver"] = json!(true); // A flag is advertised only when every method it promises is registered. capabilities["pipelinesReads"] = json!(crate::production::pipelines_rpc::pipelines_reads_registered()); @@ -2958,9 +2959,7 @@ const fn platform_arch() -> &'static str { } pub(crate) fn now_iso() -> String { - time::OffsetDateTime::now_utc() - .format(&time::format_description::well_known::Rfc3339) - .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()) + crate::persistence::now_timestamp() } #[cfg(test)] @@ -7586,6 +7585,7 @@ mod tests { assert_eq!(descriptor["capabilities"]["terminalOrderedInput"], true); assert_eq!(descriptor["capabilities"]["terminalSizeOwnership"], true); assert_eq!(descriptor["capabilities"]["terminalUserInputClaim"], true); + assert_eq!(descriptor["capabilities"]["terminalSizeHandOver"], true); assert_eq!(descriptor["capabilities"]["previewTunnels"], true); assert_eq!(descriptor["capabilities"]["terminalImagePaste"], true); assert_eq!(descriptor["capabilities"]["pullRequestCreateOptions"], true); diff --git a/apps/server/src/production/git_vcs.rs b/apps/server/src/production/git_vcs.rs index 22593dcd22..2b81030de7 100644 --- a/apps/server/src/production/git_vcs.rs +++ b/apps/server/src/production/git_vcs.rs @@ -202,13 +202,21 @@ fn observe_local_changes( ) { let summary = summary.clone(); let pull_requests = pull_requests.clone(); + // Weak: the observer lives inside the broadcaster. + let streams = broadcaster.downgrade(); broadcaster.set_local_change_observer(move |cwd| { - pull_requests.invalidate_open_pull_request_lookups(cwd); + let outdated_lookup = pull_requests.invalidate_open_pull_request_lookups(cwd); let Ok(handle) = tokio::runtime::Handle::try_current() else { return; }; let summary = summary.clone(); let cwd = cwd.to_path_buf(); + // R30: a change that alters no status publishes nothing, so no stream would look up + // again after the lookup it outdated. Republishing the remote makes each one do so. + if outdated_lookup && let Some(broadcaster) = streams.upgrade() { + let cwd = cwd.clone(); + handle.spawn(async move { broadcaster.notify_pull_request_change(&cwd).await }); + } handle.spawn(async move { summary.notify_local_change(&cwd).await }); }); } @@ -588,6 +596,12 @@ impl GitVcsRpcServices { pub(crate) fn status_broadcaster(&self) -> StatusBroadcaster { self.broadcaster.clone() } + + /// The service whose shared lookups quiesce ends; take it after the last builder call, + /// which may replace it. + pub(crate) fn pull_requests(&self) -> PullRequestService { + self.pull_requests.clone() + } } impl WorkspaceMutationObserver for GitVcsRpcServices { @@ -921,8 +935,13 @@ impl GitVcsRpcServices { .await } }); + // The RPC's own cancellation is not the lookup's result, so not a failure. + let mut refresh_cancelled = false; let found = tokio::select! { - () = cancellation.cancelled() => Ok(None), + () = cancellation.cancelled() => { + refresh_cancelled = true; + Ok(None) + } found = tokio::time::timeout( REFRESH_PULL_REQUEST_LOOKUP_BOUND, &mut lookup, @@ -937,9 +956,19 @@ impl GitVcsRpcServices { ); pull_request } - Ok(None) => self - .broadcaster - .known_pull_request(&cwd, ref_name.as_deref()), + Ok(None) => { + // The inner lookup runs under a token nothing cancels: a failure. + // Subscribers show the cleared request too, as after a late lookup. + if !refresh_cancelled + && self + .broadcaster + .note_failed_pull_request_lookup(&cwd, ref_name.as_deref()) + { + self.broadcaster.notify_pull_request_change(&cwd).await; + } + self.broadcaster + .known_pull_request(&cwd, ref_name.as_deref()) + } Err(_) => { // Only subscribers need the late answer, so the lookup keeps its // lease while the repository has one and then delivers to them. @@ -954,6 +983,12 @@ impl GitVcsRpcServices { pull_request, ); broadcaster.notify_pull_request_change(&late_cwd).await; + } else if broadcaster + .note_failed_pull_request_lookup(&late_cwd, late_ref.as_deref()) + { + // Republished so the subscriber shows the cleared request; + // its own lookup then answers from the failure backoff. + broadcaster.notify_pull_request_change(&late_cwd).await; } }); self.broadcaster @@ -1532,12 +1567,20 @@ impl GitVcsRpcServices { (&mut enrichment.as_mut().expect("lookup in flight").task).await }, if enrichment.is_some() => { let finished = enrichment.take().expect("lookup in flight"); - if let Ok(Some(pull_request)) = found { - broadcaster.record_pull_request( + match found { + Ok(Some(pull_request)) => broadcaster.record_pull_request( &cwd, finished.ref_name.as_deref(), pull_request, - ); + ), + // A lookup cancelled through its own token is not a failure. + Ok(None) if !finished.cancellation.is_cancelled() => { + broadcaster.note_failed_pull_request_lookup( + &cwd, + finished.ref_name.as_deref(), + ); + } + _ => {} } if !shown.reconcile(&sender, &broadcaster, &cwd).await { break; @@ -8227,8 +8270,8 @@ mod open_pull_request_lookup_tests { use crate::test_support::TestSandbox; /// Answers every `pr list` from marker files read when it starts, then - /// records its `--head` branch: `fail` exits 1, `slow` sleeps its contents - /// in seconds, and `open` returns one open PR for the requested branch. + /// records its `--head` branch: `slow` sleeps its contents in seconds, then + /// `fail` exits 1, and `open` returns one open PR for the requested branch. /// The markers are read before the record, so a test that sees a lookup /// recorded can change a marker without the change reaching that lookup. const FAKE_GH: &str = r#"#!/bin/sh @@ -8239,8 +8282,8 @@ case "$1:$2" in slow=''; if [ -f 'STATE/slow' ]; then slow="$(cat 'STATE/slow')"; fi printf '%s\n' "$4" >> 'CALLS' printf '%s\n' "$$" >> 'PIDS' - if [ -n "$fail" ]; then echo 'forge unavailable' >&2; exit 1; fi if [ -n "$slow" ]; then sleep "$slow"; fi + if [ -n "$fail" ]; then echo 'forge unavailable' >&2; exit 1; fi if [ -n "$open" ]; then printf '[{"number":42,"title":"PR for %s","url":"https://github.com/acme/repo/pull/42","baseRefName":"main","headRefName":"%s","state":"OPEN"}]\n' "$4" "$4" else @@ -8860,11 +8903,22 @@ esac fixture.clear("fail"); fixture.set("open", ""); + // A push, a mutation or a local change clears the remembered failure in production. + fixture.forget_lookups(); let recovered = fixture.refresh().await; assert_eq!(recovered["pr"]["number"], 42, "{recovered}"); assert_eq!(fixture.lookups(), ["main", "main"]); } + #[tokio::test] + async fn a_failed_lookup_is_not_run_again_inside_the_backoff() { + let fixture = Fixture::new("pr-lookup-backoff"); + fixture.set("fail", ""); + fixture.refresh().await; + fixture.refresh().await; + assert_eq!(fixture.lookups(), ["main"], "one gh run"); + } + /// Subscribes and waits until the subscriber shows the open pull request. async fn subscriber_showing_the_pull_request( fixture: &Fixture, @@ -8989,6 +9043,81 @@ esac cancellation.cancel(); } + /// Moves the subscriber's remembered success back by `age` on the real clock. + fn age_the_pull_request(fixture: &Fixture, age: Duration) { + let canonical = std::fs::canonicalize(&fixture.repository).expect("canonical repository"); + fixture + .services + .broadcaster + .age_pull_request_for_test(&canonical, age); + } + + #[tokio::test] + async fn pr_row_a_failed_lookup_after_six_minutes_clears_the_pull_request() { + let fixture = Fixture::new("pr-row-stale-failed"); + let cancellation = CancellationToken::new(); + let mut subscriber = subscriber_showing_the_pull_request(&fixture, &cancellation).await; + + age_the_pull_request(&fixture, Duration::from_secs(360)); + fixture.set("fail", ""); + fixture.forget_lookups(); + fixture.report_pull_request_change(); + subscriber + .until("a failed lookup on stale data clears the request", |s| { + s.pr().is_null() + }) + .await; + cancellation.cancel(); + } + + #[tokio::test] + async fn pr_row_a_failed_lookup_after_four_minutes_keeps_the_pull_request() { + let fixture = Fixture::new("pr-row-fresh-failed"); + let cancellation = CancellationToken::new(); + let mut subscriber = subscriber_showing_the_pull_request(&fixture, &cancellation).await; + let from = subscriber.events.len(); + + age_the_pull_request(&fixture, Duration::from_secs(240)); + fixture.set("fail", ""); + fixture.forget_lookups(); + fixture.report_pull_request_change(); + wait_for_lookups(&fixture, "main", 2).await; + subscriber.drain(Duration::from_millis(1000)).await; + + assert!( + subscriber.events.len() > from, + "the test drove no status event" + ); + assert!( + subscriber.shown_since(from).iter().all(|pr| *pr == 42), + "a failed lookup on recent data keeps the request: {:?}", + subscriber.events + ); + cancellation.cancel(); + } + + #[tokio::test] + async fn pr_row_a_late_failed_lookup_after_six_minutes_clears_the_pull_request() { + let fixture = Fixture::new("pr-row-late-stale-failed"); + let cancellation = CancellationToken::new(); + let mut subscriber = subscriber_showing_the_pull_request(&fixture, &cancellation).await; + + age_the_pull_request(&fixture, Duration::from_secs(360)); + fixture.set("slow", "6"); + fixture.set("fail", ""); + fixture.forget_lookups(); + let status = tokio::time::timeout(Duration::from_secs(5), fixture.refresh()) + .await + .expect("the refresh answers at its bound"); + assert_eq!(status["pr"]["number"], 42, "{status}"); + subscriber + .until("the late failed lookup clears the request", |s| { + s.pr().is_null() + }) + .await; + cancellation.cancel(); + } + #[tokio::test] async fn pr_row_a_ref_change_clears_the_pull_request_at_once() { let fixture = Fixture::new("pr-row-ref-change"); @@ -9196,6 +9325,65 @@ esac cancellation.cancel(); } + /// R30: like the test above, but the local change alters no status. Decided by test: + /// defect. The change invalidated the lookup, but an equal local status publishes nothing, + /// so the stream never looked up again and kept the outdated answer. The local-change + /// observer now republishes the remote when it drops an awaited lookup. + #[tokio::test] + async fn a_neutral_local_change_during_a_lookup_replaces_its_outdated_answer() { + let fixture = Fixture::new("pr-lookup-neutral-again"); + let cancellation = CancellationToken::new(); + let mut subscriber = subscriber_showing_the_pull_request(&fixture, &cancellation).await; + + // This lookup reads the open request, then answers slowly. + fixture.set("slow", "2"); + fixture.report_pull_request_change(); + wait_for_lookups(&fixture, "main", 2).await; + tokio::time::sleep(Duration::from_millis(300)).await; + // The request is closed, and a local change that alters nothing reports it. + fixture.clear("open"); + fixture + .services + .broadcaster + .notify_local_change(&fixture.repository) + .await; + // First, so a missing follow-up lookup fails with the calls the forge saw. + wait_for_lookups(&fixture, "main", 3).await; + subscriber + .until( + "the closed request must clear after the outdated answer", + |s| s.pr().is_null(), + ) + .await; + assert_eq!( + fixture.lookups(), + ["main", "main", "main"], + "one follow-up lookup runs after the outdated one" + ); + cancellation.cancel(); + } + + #[tokio::test] + async fn pr_row_a_failed_refresh_after_six_minutes_clears_the_pull_request() { + let fixture = Fixture::new("pr-row-refresh-stale-failed"); + let cancellation = CancellationToken::new(); + let mut subscriber = subscriber_showing_the_pull_request(&fixture, &cancellation).await; + + age_the_pull_request(&fixture, Duration::from_secs(360)); + fixture.set("fail", ""); + fixture.forget_lookups(); + let status = tokio::time::timeout(Duration::from_secs(5), fixture.refresh()) + .await + .expect("the refresh answers inside its bound"); + assert!(status["pr"].is_null(), "{status}"); + subscriber + .until("the subscriber shows the cleared request", |s| { + s.pr().is_null() + }) + .await; + cancellation.cancel(); + } + #[tokio::test] async fn a_queued_publication_shows_the_known_pull_request_at_once() { let fixture = Fixture::new("pr-queued-publication"); @@ -9293,6 +9481,39 @@ esac .await; cancellation.cancel(); } + + #[tokio::test] + async fn pull_request_lookup_shutdown_reaps_the_forge_cli() { + let fixture = Fixture::new("pr-lookup-shutdown"); + let cancellation = CancellationToken::new(); + let mut subscriber = fixture.subscribe(&cancellation); + wait_for_lookups(&fixture, "main", 1).await; + subscriber.drain(Duration::from_millis(300)).await; + + // The refresh answers at its bound and hands the lookup to the subscriber. + fixture.set("slow", "30"); + fixture.forget_lookups(); + tokio::time::timeout(Duration::from_secs(5), fixture.refresh()) + .await + .expect("the refresh answers at its bound"); + wait_for_lookups(&fixture, "main", 2).await; + assert!(fixture.a_forge_cli_is_running(), "the late lookup runs"); + + tokio::time::timeout( + Duration::from_secs(30), + fixture + .services + .pull_requests() + .shutdown_open_pull_request_lookups(), + ) + .await + .expect("shutdown ends the lookups"); + assert!( + !fixture.a_forge_cli_is_running(), + "shutdown returned while the forge CLI still ran" + ); + cancellation.cancel(); + } } #[cfg(test)] diff --git a/apps/server/src/production/orchestration_effects.rs b/apps/server/src/production/orchestration_effects.rs index f60cd5dca4..f2e647b9de 100644 --- a/apps/server/src/production/orchestration_effects.rs +++ b/apps/server/src/production/orchestration_effects.rs @@ -12,7 +12,6 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use thiserror::Error; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::{ sync::{Semaphore, broadcast, mpsc, oneshot}, task::JoinHandle, @@ -4150,9 +4149,7 @@ fn required_str<'a>(value: &'a Value, field: &str) -> Result<&'a str, Orchestrat } fn now_iso() -> String { - OffsetDateTime::now_utc() - .format(&Rfc3339) - .unwrap_or_else(|_| OffsetDateTime::now_utc().unix_timestamp().to_string()) + crate::persistence::now_timestamp() } fn server_id(tag: &str) -> String { diff --git a/apps/server/src/production/orchestration_rpc.rs b/apps/server/src/production/orchestration_rpc.rs index 60c1343140..fee65645ea 100644 --- a/apps/server/src/production/orchestration_rpc.rs +++ b/apps/server/src/production/orchestration_rpc.rs @@ -308,6 +308,87 @@ fn register_orchestration_rpc_inner( ); } +/// The command's own timestamp fields, at its root, that a client fills from its own clock. +const CLIENT_TIMESTAMP_FIELDS: &[&str] = + &["createdAt", "updatedAt", "completedAt", "implementedAt"]; + +/// The typed nested timestamp fields of each command type, as paths from the command's root; +/// `*` is every item of an array. +const CLIENT_NESTED_TIMESTAMP_PATHS: &[(&str, &[&str])] = &[ + ( + "thread.turn.start", + &["bootstrap", "createThread", "createdAt"], + ), + ("thread.history.import", &["messages", "*", "createdAt"]), + ( + "thread.proposed-plan.upsert", + &["proposedPlan", "createdAt"], + ), + ( + "thread.proposed-plan.upsert", + &["proposedPlan", "updatedAt"], + ), + ( + "thread.proposed-plan.upsert", + &["proposedPlan", "implementedAt"], + ), + ("thread.activity.append", &["activity", "createdAt"]), + ("thread.session.set", &["session", "updatedAt"]), +]; + +/// A client writes `toISOString`'s three fraction digits, but the store compares timestamps as +/// text at the server's one width (#366), so each of the command's own timestamp fields is +/// re-written in the store form before the command is decoded. Only those explicit fields: +/// answers, tool payloads, provider data and message text stay byte for byte, even under a key +/// named like a timestamp field. Text that does not parse as RFC 3339 is kept as sent, as before. +/// The payload digest is taken from the bytes as sent, before this runs. +fn canonicalize_client_timestamps(payload: &mut Value) { + let kind = payload + .get("type") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + if let Value::Object(fields) = payload { + for key in CLIENT_TIMESTAMP_FIELDS { + if let Some(field) = fields.get_mut(*key) { + canonicalize_timestamp_field(field); + } + } + } + for (_, path) in CLIENT_NESTED_TIMESTAMP_PATHS + .iter() + .filter(|(command, _)| *command == kind) + { + canonicalize_timestamp_at(payload, path); + } +} + +fn canonicalize_timestamp_at(value: &mut Value, path: &[&str]) { + match path { + [] => canonicalize_timestamp_field(value), + ["*", rest @ ..] => { + if let Value::Array(items) = value { + for item in items { + canonicalize_timestamp_at(item, rest); + } + } + } + [key, rest @ ..] => { + if let Some(next) = value.get_mut(*key) { + canonicalize_timestamp_at(next, rest); + } + } + } +} + +fn canonicalize_timestamp_field(field: &mut Value) { + if let Value::String(text) = field + && let Some(canonical) = crate::persistence::canonical_timestamp(text) + { + *text = canonical; + } +} + /// Decodes the public orchestration wire shape and resolves the small amount of /// server-owned context that ordinary commands need. Worktree policy, identity, /// lifecycle kind, and adopted-owner deletion are deliberately not part of this @@ -321,6 +402,8 @@ pub(crate) async fn decode_public_orchestration_command( .and_then(|bootstrap| bootstrap.get("prepareWorktree")) .and_then(Value::as_object) .is_some_and(|prepare| prepare.contains_key("projectCwd")); + let mut payload = payload; + canonicalize_client_timestamps(&mut payload); let mut command = serde_json::from_value::(payload) .map_err(|error| error.to_string())?; if command.is_server_internal() { @@ -4655,6 +4738,115 @@ mod tests { .len() } + /// #366: a client's three-digit instant is stored in the server's nine-digit form, so a + /// client message and a server message in the same millisecond list in time order. + #[tokio::test] + async fn a_client_created_at_sorts_with_server_rows_in_the_same_millisecond() { + let engine = migrated_engine().await; + let command = decode_public_orchestration_command( + &engine, + json!({ + "type":"thread.turn.start","commandId":"client-turn","threadId":"thread-a", + "message":{"messageId":"m-client","role":"user","text":"hi","attachments":[]}, + "createdAt":"2026-01-01T00:00:00.120Z" + }), + ) + .await + .expect("the client command decodes"); + let client_at = serde_json::to_value(&command).unwrap()["createdAt"] + .as_str() + .unwrap() + .to_owned(); + // A server message 500 microseconds later, in the same millisecond. + let server_at = crate::persistence::format_timestamp( + time::OffsetDateTime::from_unix_timestamp_nanos(1_767_225_600_120_500_000).unwrap(), + ); + engine + .repositories() + .database() + .call(move |connection| { + for (message_id, role, at) in [ + ("m-server", "assistant", server_at), + ("m-client", "user", client_at), + ] { + connection.execute( + "INSERT INTO projection_thread_messages (message_id, thread_id, role, \ + text, is_streaming, created_at, updated_at) \ + VALUES (?1, 'thread-a', ?2, '', 0, ?3, ?3)", + rusqlite::params![message_id, role, at], + )?; + } + Ok(()) + }) + .await + .unwrap(); + let order: Vec = engine + .repositories() + .list_messages_by_thread("thread-a".to_owned()) + .await + .unwrap() + .into_iter() + .map(|message| message.message_id) + .collect(); + assert_eq!(order, ["m-client", "m-server"]); + } + + /// #366 review: a user's answer is free-form data, even under a question id named like a + /// timestamp field, so it reaches the provider byte for byte; only the command's own + /// `createdAt` is re-written. + #[tokio::test] + async fn a_user_answer_keyed_like_a_timestamp_field_is_kept_verbatim() { + let engine = migrated_engine().await; + let command = decode_public_orchestration_command( + &engine, + json!({ + "type":"thread.user-input.respond","commandId":"answer","threadId":"thread-a", + "requestId":"request-a", + "answers":{"createdAt":"2026-01-01T01:00:00+01:00"}, + "createdAt":"2026-01-01T00:00:00.120Z" + }), + ) + .await + .expect("the answer decodes"); + let decoded = serde_json::to_value(&command).unwrap(); + assert_eq!(decoded["answers"]["createdAt"], "2026-01-01T01:00:00+01:00"); + assert_eq!(decoded["createdAt"], "2026-01-01T00:00:00.120000000Z"); + } + + /// #366: the ingestion re-writes each client instant, nested ones included, and keeps text + /// that is not RFC 3339 as sent. + #[test] + fn client_timestamps_are_rewritten_and_unparsable_text_is_kept() { + let mut payload = json!({ + "type":"thread.turn.start", + "createdAt":"2026-01-01T00:00:00.120Z", + "bootstrap":{"createThread":{"createdAt":"2026-01-01T01:00:00+01:00"}}, + "message":{"text":"2026-01-01T00:00:00Z","createdAt":"2026-01-01T00:00:00Z"} + }); + canonicalize_client_timestamps(&mut payload); + assert_eq!(payload["createdAt"], "2026-01-01T00:00:00.120000000Z"); + assert_eq!( + payload["bootstrap"]["createThread"]["createdAt"], + "2026-01-01T00:00:00.000000000Z" + ); + // Not one of the command's timestamp fields: kept byte for byte. + assert_eq!(payload["message"]["text"], "2026-01-01T00:00:00Z"); + assert_eq!(payload["message"]["createdAt"], "2026-01-01T00:00:00Z"); + + let mut imported = json!({ + "type":"thread.history.import", + "createdAt":"not a time", + "messages":[{"createdAt":"2026-01-01T00:00:00.5Z"},{"createdAt":"yesterday"}] + }); + canonicalize_client_timestamps(&mut imported); + assert_eq!(imported["createdAt"], "not a time"); + assert_eq!( + imported["messages"][0]["createdAt"], + "2026-01-01T00:00:00.500000000Z" + ); + assert_eq!(imported["messages"][1]["createdAt"], "yesterday"); + } + /// Clients cannot set the server-authored identity: serde drops the unknown field, so the /// command decodes, applies its other fields, and leaves the stored identity untouched. #[tokio::test] diff --git a/apps/server/src/production/provider_runtime.rs b/apps/server/src/production/provider_runtime.rs index 893e28c213..712c1a7da0 100644 --- a/apps/server/src/production/provider_runtime.rs +++ b/apps/server/src/production/provider_runtime.rs @@ -112,7 +112,7 @@ use futures_util::{StreamExt, stream}; use process_wrap::tokio::{ChildWrapper, CommandWrap}; use serde_json::{Value, json}; use thiserror::Error; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use time::OffsetDateTime; use tokio::{ io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}, sync::{Mutex, RwLock, mpsc, oneshot, watch}, @@ -8962,9 +8962,7 @@ async fn confirm_late_delivery(engine: &OrchestrationEngine, thread_id: &str, de expected_attempt: row.attempts, next_state: TurnDeliveryState::Delivered, detail: None, - updated_at: OffsetDateTime::now_utc() - .format(&Rfc3339) - .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()), + updated_at: crate::persistence::now_timestamp(), }; match engine.transition_turn_delivery(transition).await { Ok(true) => { @@ -14197,9 +14195,7 @@ fn retained_activity_health() -> ActivitySectionHealth { } fn now() -> String { - OffsetDateTime::now_utc() - .format(&Rfc3339) - .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()) + crate::persistence::now_timestamp() } /// The provider inventory the server last published: one snapshot per provider instance, each diff --git a/apps/server/src/production/runtime.rs b/apps/server/src/production/runtime.rs index ec50a161bc..fd9a1bc325 100644 --- a/apps/server/src/production/runtime.rs +++ b/apps/server/src/production/runtime.rs @@ -9,7 +9,7 @@ use std::{ use axum::http::StatusCode; use serde_json::{Value, json}; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use time::OffsetDateTime; use tokio::{io::AsyncReadExt, process::Command}; use tokio_util::sync::CancellationToken; @@ -149,6 +149,8 @@ pub struct ProductionRuntime { /// The server's repository, whose Git writes shutdown stops (R17). git_repository: Arc, status_broadcaster: crate::git::StatusBroadcaster, + /// Status enrichment's shared open pull-request lookups, which quiesce ends (R26). + shared_pull_requests: crate::source_control::PullRequestService, workspace: WorkspaceRpc, _resource_sampler: Arc, process_monitor: Arc>, @@ -563,6 +565,7 @@ impl ProductionRuntime { let worktree_removal_tasks = git_vcs.worktree_removal_tasks(); let clone_operations = git_vcs.clone_operations(); let status_broadcaster = git_vcs.status_broadcaster(); + let shared_pull_requests = git_vcs.pull_requests(); let terminal_status_broadcaster = status_broadcaster.clone(); terminal_manager.set_process_exit_callback(Arc::new(move |cwd| { let status_broadcaster = terminal_status_broadcaster.clone(); @@ -849,6 +852,7 @@ impl ProductionRuntime { clone_operations, git_repository, status_broadcaster, + shared_pull_requests, workspace, _resource_sampler: resource_sampler, process_monitor, @@ -1044,6 +1048,12 @@ impl ProductionRuntime { steps.mark("workspace"); self.status_broadcaster.shutdown().await; steps.mark("status-broadcaster"); + // Every running pull-request lookup ends, its forge CLI reaped, and later ones are + // refused, before the Git writes stop and the profiler's teardown (R26). + self.shared_pull_requests + .shutdown_open_pull_request_lookups() + .await; + steps.mark("pull-request-lookups"); self.worktree_catalog_operations.shutdown().await; steps.mark("worktree-catalog-operations"); self._worktree_catalog.shutdown().await; @@ -2162,9 +2172,7 @@ fn now_millis() -> u64 { } fn now_iso() -> String { - OffsetDateTime::now_utc() - .format(&Rfc3339) - .unwrap_or_else(|_| OffsetDateTime::now_utc().unix_timestamp().to_string()) + crate::persistence::now_timestamp() } const FALLBACK_FAVICON: &str = r##""##; @@ -5564,9 +5572,15 @@ mod tests { /// R17: a Git write still running at shutdown is stopped, and its `index.lock` is gone when /// quiescence returns. The write is a `git add` whose clean filter never finishes. async fn start_quiesce_runtime(state: &TempDir) -> ProductionRuntime { - let config = ServerConfig::new(state.path()) - .with_bind("127.0.0.1", 0) - .with_unsafe_no_auth(); + start_quiesce_runtime_with( + ServerConfig::new(state.path()) + .with_bind("127.0.0.1", 0) + .with_unsafe_no_auth(), + ) + .await + } + + async fn start_quiesce_runtime_with(config: ServerConfig) -> ProductionRuntime { hermetic_providers::write_hermetic_settings(&config.state_dir(), json!({})); let database = Database::open_in_memory().await.expect("database"); database @@ -5587,6 +5601,75 @@ mod tests { .expect("runtime") } + /// R26: a pull-request lookup still running at shutdown ends, and its forge CLI is gone, + /// when quiescence returns. + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn quiesce_in_stops_a_running_pull_request_lookup() { + let state = TempDir::new().expect("state"); + let bin = state.path().join("bin"); + let worktree = state.path().join("worktree"); + let pid_file = state.path().join("gh-pid"); + std::fs::create_dir(&bin).expect("bin"); + std::fs::create_dir(&worktree).expect("worktree"); + crate::test_support::TestSandbox::write_executable( + &bin.join("gh"), + &format!( + "#!/bin/sh\ncase \"$1:$2\" in\n pr:list) echo $$ > '{}.tmp'; mv '{0}.tmp' '{0}'; exec sleep 30 ;;\n *) exit 64 ;;\nesac\n", + pid_file.display() + ), + ); + let runtime = start_quiesce_runtime_with( + ServerConfig::new(state.path()) + .with_bind("127.0.0.1", 0) + .with_unsafe_no_auth() + .with_hosting_executable_dir_for_integration_test(bin), + ) + .await; + + let lookup = tokio::spawn({ + let pull_requests = runtime.shared_pull_requests.clone(); + async move { + pull_requests + .resolve_current_optional_shared( + crate::source_control::ResolvePullRequestInput { + cwd: worktree, + provider: crate::source_control::ProviderKind::Github, + reference: "main".into(), + }, + &CancellationToken::new(), + ) + .await + } + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let pid = loop { + if let Ok(pid) = std::fs::read_to_string(&pid_file) { + break pid.trim().to_owned(); + } + assert!(std::time::Instant::now() < deadline, "the lookup started"); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + }; + let alive = |pid: &str| { + std::process::Command::new("kill") + .args(["-0", pid]) + .status() + .expect("kill runs") + .success() + }; + assert!(alive(&pid), "the forge CLI runs"); + + runtime.quiesce_for_update().await.expect("quiesced"); + + assert!(!alive(&pid), "the forge CLI outlived quiescence"); + let error = timeout(std::time::Duration::from_secs(5), lookup) + .await + .expect("the lookup has returned") + .expect("lookup task") + .expect_err("the lookup was cancelled"); + assert_eq!(&*error.detail, "The pull-request lookup was cancelled."); + } + /// A write whose stop outlasts both shutdown waits keeps quiescence incomplete: a later /// call waits for it again and succeeds only once it has settled. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/apps/server/src/production/turn_delivery.rs b/apps/server/src/production/turn_delivery.rs index a7cfdc21f5..3a162bb48f 100644 --- a/apps/server/src/production/turn_delivery.rs +++ b/apps/server/src/production/turn_delivery.rs @@ -2034,9 +2034,7 @@ fn provider_delivery_outcome( } fn now() -> String { - OffsetDateTime::now_utc() - .format(&Rfc3339) - .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()) + crate::persistence::now_timestamp() } #[cfg(test)] diff --git a/apps/server/src/production/worktree_catalog_rpc.rs b/apps/server/src/production/worktree_catalog_rpc.rs index 0f76724572..774ac5d8c8 100644 --- a/apps/server/src/production/worktree_catalog_rpc.rs +++ b/apps/server/src/production/worktree_catalog_rpc.rs @@ -12,7 +12,6 @@ use std::{ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::{ sync::{Mutex, Semaphore, TryAcquireError, oneshot, watch}, task::JoinHandle, @@ -351,7 +350,7 @@ pub struct WorktreeCatalogRpcServices { panel_dispatch_gate: Option, #[cfg(test)] removal_admission_timeout: Duration, - #[cfg(test)] + /// Receives one permit each time a removal starts waiting for admitted work, for tests. removal_drain_entered: Option>, } @@ -534,7 +533,6 @@ impl WorktreeCatalogRpcServices { panel_dispatch_gate: None, #[cfg(test)] removal_admission_timeout: REMOVAL_ADMISSION_DRAIN_TIMEOUT, - #[cfg(test)] removal_drain_entered: None, } } @@ -545,6 +543,18 @@ impl WorktreeCatalogRpcServices { self } + /// Adds one permit to `entered` each time a removal starts waiting for admitted work to + /// drain, so an integration test can act inside the drain without guessing its timing. + #[doc(hidden)] + #[must_use] + pub fn with_removal_drain_signal_for_integration_test( + mut self, + entered: Arc, + ) -> Self { + self.removal_drain_entered = Some(entered); + self + } + #[doc(hidden)] #[must_use] pub fn with_panel_dispatch_gate( @@ -1278,6 +1288,12 @@ async fn create_managed_worktree( .await } +/// The `create-rollback` outcome of a failed creation's rollback: a fixed reason token, never +/// the rollback's detail or a path. +fn create_rollback_outcome(rolled_back: bool) -> Result<(), &'static str> { + if rolled_back { Ok(()) } else { Err("internal") } +} + async fn create_managed_worktree_locked( services: &WorktreeCatalogRpcServices, input: WorktreeCreateManagedInput, @@ -1348,27 +1364,41 @@ async fn create_managed_worktree_locked( )) })?; let repositories = services.orchestration.repositories(); - if let Err(error) = repositories + let record_id = match repositories .record_worktree_creation(&project.workspace_root, &created.worktree.path) .await { - tracing::error!(%error, "managed worktree creation could not record the worktree"); - let rollback = repository - .rollback_managed_worktree_creation(Path::new(&project.workspace_root), &rollback) - .await; - let detail = match rollback { - Ok(()) => format!("The worktree creation could not be recorded: {error}"), - Err(rollback) => format!( - "The worktree creation could not be recorded: {error}; exact managed-worktree rollback also failed: {}", - rollback.detail - ), - }; - return Err(encode(adoption_error( - WorktreeAdoptionErrorReason::Internal, - detail, - None, - ))); - } + Ok(record_id) => record_id, + Err(error) => { + tracing::error!(%error, "managed worktree creation could not record the worktree"); + let started = Instant::now(); + let rollback = repository + .rollback_managed_worktree_creation( + Path::new(&project.workspace_root), + &rollback, + ) + .await; + record_removal_outcome( + "create-rollback", + create_rollback_outcome(rollback.is_ok()), + started.elapsed(), + false, + cancellation.is_cancelled(), + ); + let detail = match rollback { + Ok(()) => format!("The worktree creation could not be recorded: {error}"), + Err(rollback) => format!( + "The worktree creation could not be recorded: {error}; exact managed-worktree rollback also failed: {}", + rollback.detail + ), + }; + return Err(encode(adoption_error( + WorktreeAdoptionErrorReason::Internal, + detail, + None, + ))); + } + }; let terminal_cancellation = CancellationToken::new(); let dispatch = services .orchestration @@ -1400,13 +1430,19 @@ async fn create_managed_worktree_locked( ) .await; if let Err(error) = dispatch { + let started = Instant::now(); let rollback = repository .rollback_managed_worktree_creation(Path::new(&project.workspace_root), &rollback) .await; + record_removal_outcome( + "create-rollback", + create_rollback_outcome(rollback.is_ok()), + started.elapsed(), + false, + cancellation.is_cancelled(), + ); if rollback.is_ok() - && let Err(error) = repositories - .delete_worktree_creation_record(&created.worktree.path) - .await + && let Err(error) = repositories.reap_worktree_creation_record(record_id).await { tracing::warn!(%error, "a rolled-back worktree kept its creation record"); } @@ -1836,8 +1872,9 @@ struct ResolvedRemovalPlan { struct ResolvedRemovalThread { path: PathBuf, known_thread_ids: Vec, - /// The worktree path as stored, the key of the worktree's creation record. - creation_record: String, + /// The worktree's creation record when resolved, deleted by id so that a worktree + /// recreated at the same path meanwhile keeps its own record. + creation_record_id: Option, } #[derive(Clone, Serialize)] @@ -2272,7 +2309,6 @@ async fn remove_worktree_owned( let drain_timeout = REMOVAL_ADMISSION_DRAIN_TIMEOUT; #[cfg(test)] let drain_timeout = services.removal_admission_timeout; - #[cfg(test)] if let Some(entered) = &services.removal_drain_entered { entered.add_permits(1); } @@ -2409,11 +2445,12 @@ async fn remove_worktree_owned( drop(guard); if matches!(git_outcome, "removed" | "cleaned") { // The folder is gone. A failed delete is reaped by the next worktree audit. - if let Err(error) = services - .orchestration - .repositories() - .delete_worktree_creation_record(&resolved.creation_record) - .await + if let Some(record_id) = resolved.creation_record_id + && let Err(error) = services + .orchestration + .repositories() + .reap_worktree_creation_record(record_id) + .await { tracing::warn!(%error, "a removed worktree kept its creation record"); } @@ -2549,10 +2586,17 @@ async fn resolve_removal_thread( known_thread_ids.push(thread_id.to_owned()); known_thread_ids.sort(); known_thread_ids.dedup(); + // An unreadable record never refuses a removal; the next worktree audit reaps it. + let creation_record_id = repositories + .worktree_creation_record_id(&persisted_path.to_string_lossy()) + .await + .inspect_err(|error| tracing::warn!(%error, "a removal could not read its creation record")) + .ok() + .flatten(); Ok(ResolvedRemovalThread { path, known_thread_ids, - creation_record: persisted_path.to_string_lossy().into_owned(), + creation_record_id, }) } @@ -3791,9 +3835,7 @@ fn stale_generation(expected: u64, current: Option) -> CatalogError { } fn now_iso() -> String { - OffsetDateTime::now_utc() - .format(&Rfc3339) - .unwrap_or_else(|_| OffsetDateTime::now_utc().unix_timestamp().to_string()) + crate::persistence::now_timestamp() } #[cfg(test)] @@ -4993,15 +5035,33 @@ mod mutation_invalidation_tests { let mut fixture = RemovalFixture::new().await; fixture.set_live_sessions(&["removed-thread"]); fixture.handler.services.removal_admission_timeout = Duration::from_millis(20); + let entered = Arc::new(Semaphore::new(0)); + fixture.handler.services.removal_drain_entered = Some(Arc::clone(&entered)); let payload = fixture.payload().await; let registry = fixture.handler.catalog.availability_registry(); let _admission = registry .acquire_admission("removed-thread", [fixture.linked.as_path()]) .await .expect("held admission"); - let result = tokio::time::timeout(Duration::from_secs(2), fixture.remove(payload)) + let services = fixture.handler.services.clone(); + let removal = tokio::spawn(async move { + remove_worktree( + &services, + request("worktree.remove", payload), + CancellationToken::new(), + ) .await - .expect("bounded admission drain"); + }); + // The 30 s guards are reached only by a real hang, never by a slow machine. + tokio::time::timeout(Duration::from_secs(30), entered.acquire()) + .await + .expect("removal reaches the admission drain") + .expect("drain signal") + .forget(); + let result = tokio::time::timeout(Duration::from_secs(30), removal) + .await + .expect("removal ends after the drain deadline") + .expect("removal joins"); fixture.assert_refused(result).await; fixture.handler.shutdown().await; } @@ -5499,6 +5559,65 @@ mod mutation_invalidation_tests { fixture.shutdown().await; } + #[tokio::test] + async fn removal_keeps_a_record_created_while_it_drains() { + let mut fixture = RemovalFixture::new().await; + // Long enough that only a real hang, not a loaded machine, refuses the removal. + fixture.handler.services.removal_admission_timeout = Duration::from_secs(30); + let entered = Arc::new(Semaphore::new(0)); + fixture.handler.services.removal_drain_entered = Some(Arc::clone(&entered)); + let repositories = fixture.handler.engine.repositories(); + let linked = fixture.linked.to_string_lossy().into_owned(); + repositories + .record_worktree_creation(&fixture.handler.main.to_string_lossy(), &linked) + .await + .expect("first creation record"); + let payload = fixture.payload().await; + let registry = fixture.handler.catalog.availability_registry(); + let admission = registry + .acquire_admission("removed-thread", [fixture.linked.as_path()]) + .await + .expect("held admission"); + let services = fixture.handler.services.clone(); + let removal = tokio::spawn(async move { + remove_worktree( + &services, + request("worktree.remove", payload), + CancellationToken::new(), + ) + .await + }); + tokio::time::timeout(Duration::from_secs(30), entered.acquire()) + .await + .expect("removal reaches the admission drain") + .expect("drain signal") + .forget(); + // A worktree recreated at the same path after the removal resolved its record. + let recreated = repositories + .record_worktree_creation(&fixture.handler.main.to_string_lossy(), &linked) + .await + .expect("second creation record"); + drop(admission); + tokio::time::timeout(Duration::from_secs(30), removal) + .await + .expect("removal ends") + .expect("removal joins") + .expect("removal succeeds"); + let records = repositories + .list_worktree_creation_records() + .await + .expect("creation records"); + assert_eq!( + records + .iter() + .map(|record| record.record_id) + .collect::>(), + [recreated], + "the recreated worktree keeps its record: {records:?}" + ); + fixture.handler.shutdown().await; + } + #[tokio::test] async fn a_bootstrap_worktree_record_ends_with_its_catalog_removal() { let fixture = HandlerFixture::new(Arc::new(super::NoopWorktreeRemovalQuiescer)).await; @@ -5583,6 +5702,71 @@ mod mutation_invalidation_tests { fixture.shutdown().await; } + #[test] + fn create_rollback_outcome_uses_fixed_tokens() { + assert_eq!(super::create_rollback_outcome(true), Ok(())); + assert_eq!(super::create_rollback_outcome(false), Err("internal")); + } + + #[tokio::test] + async fn create_rollback_after_a_dispatch_failure_leaves_no_worktree_or_record() { + let fixture = HandlerFixture::new(Arc::new(super::NoopWorktreeRemovalQuiescer)).await; + git(&fixture.main, &["branch", "feature/dup"]); + // The thread the creation would add already exists, so its dispatch is refused. + fixture + .engine + .dispatch(OrchestrationCommand::ThreadCreate { + command_id: "create-dup-thread".to_owned(), + thread_id: "dup-thread".to_owned(), + project_id: "project-1".to_owned(), + title: "Existing".to_owned(), + kind: Some("workspace".to_owned()), + host_thread_id: None, + side_chat: false, + purpose: None, + model_selection: json!({"instanceId":"codex","model":"gpt-5"}), + runtime_mode: "full-access".to_owned(), + interaction_mode: "default".to_owned(), + branch: None, + worktree_path: None, + created_at: "2026-08-23T00:00:01Z".to_owned(), + code_review: false, + }) + .await + .expect("thread created"); + let managed = GitRepository::default() + .managed_worktree_parent(&fixture.main) + .await + .expect("managed worktree directory"); + + let error = create_managed(&fixture, "dup", "feature/dup") + .await + .expect_err("a refused dispatch fails the creation"); + assert_eq!(error["reason"], "orchestration-failed", "{error}"); + + let leftovers: Vec<_> = std::fs::read_dir(&managed) + .map(|entries| { + entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .collect() + }) + .unwrap_or_default(); + assert!(leftovers.is_empty(), "no worktree folder: {leftovers:?}"); + assert!(creation_records(&fixture).await.is_empty(), "no record"); + let inventory = GitRepository::default() + .worktree_inventory(&fixture.main, &CancellationToken::new()) + .await + .expect("repository remains readable"); + assert_eq!( + inventory.records.len(), + 1, + "Git lists only the primary checkout: {:?}", + inventory.records + ); + fixture.shutdown().await; + } + #[tokio::test] async fn a_creation_whose_record_cannot_be_written_fails_and_leaves_no_worktree() { let fixture = HandlerFixture::new(Arc::new(super::NoopWorktreeRemovalQuiescer)).await; diff --git a/apps/server/src/production/worktree_runtime.rs b/apps/server/src/production/worktree_runtime.rs index 0f68bb3737..5cf7af6309 100644 --- a/apps/server/src/production/worktree_runtime.rs +++ b/apps/server/src/production/worktree_runtime.rs @@ -13,7 +13,6 @@ use std::{ use futures_util::{FutureExt, StreamExt, stream, stream::FuturesUnordered}; use serde_json::json; -use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::{ sync::{Semaphore, mpsc}, task::JoinHandle, @@ -1218,9 +1217,7 @@ fn availability_token(availability: AdoptedWorktreeAvailability) -> &'static str } fn now_iso() -> String { - OffsetDateTime::now_utc() - .format(&Rfc3339) - .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()) + crate::persistence::now_timestamp() } async fn workspace_thread_ids( diff --git a/apps/server/src/pull_requests/gitlab/poll.rs b/apps/server/src/pull_requests/gitlab/poll.rs index 76650c00cb..9922f47d14 100644 --- a/apps/server/src/pull_requests/gitlab/poll.rs +++ b/apps/server/src/pull_requests/gitlab/poll.rs @@ -1376,12 +1376,12 @@ pub(crate) async fn run_subscriber( /// time, per test thread: subscribers that finished joining, and ticks in /// flight. A tick in flight is the only work a test cannot see from outside. #[cfg(all(test, unix))] -mod test_progress { +pub(in crate::pull_requests) mod test_progress { use std::cell::Cell; thread_local! { - pub(super) static JOINED: Cell = const { Cell::new(0) }; - pub(super) static TICKING: Cell = const { Cell::new(0) }; + pub(in crate::pull_requests) static JOINED: Cell = const { Cell::new(0) }; + pub(in crate::pull_requests) static TICKING: Cell = const { Cell::new(0) }; } /// Counts one tick from its start until its event is broadcast, or until diff --git a/apps/server/src/pull_requests/mod.rs b/apps/server/src/pull_requests/mod.rs index c6507b96ee..f396a178dc 100644 --- a/apps/server/src/pull_requests/mod.rs +++ b/apps/server/src/pull_requests/mod.rs @@ -2593,17 +2593,26 @@ esac"#, let mut first = Box::pin(service.subscribe(input("conversation"), &|_| true, &c)); let mut second = Box::pin(service.subscribe(input("files"), &|_| true, &c)); let (mut first_result, mut second_result) = (None, None); - for step in 0..5000 { + // The paused clock moves to the next poll tick only once both subscribers joined (their + // `git` read answered) and no tick is in flight, so no process deadline fires before a + // fake CLI answers, however loaded the machine. The guard is real time. + let settled = || { + super::gitlab::poll::test_progress::JOINED.with(std::cell::Cell::get) >= 2 + && super::gitlab::poll::test_progress::TICKING.with(std::cell::Cell::get) == 0 + }; + let guard = std::time::Instant::now() + Duration::from_secs(60); + while first_result.is_none() || second_result.is_none() { + assert!( + std::time::Instant::now() < guard, + "both subscriptions end within 60 s" + ); if first_result.is_none() { first_result = poll_immediate(first.as_mut()).await; } if second_result.is_none() { second_result = poll_immediate(second.as_mut()).await; } - if first_result.is_some() && second_result.is_some() { - break; - } - if step % 200 == 199 { + if settled() && (first_result.is_none() || second_result.is_none()) { tokio::time::advance(Duration::from_secs(10)).await; } std::thread::sleep(Duration::from_millis(1)); diff --git a/apps/server/src/source_control/open_pull_request_lookups.rs b/apps/server/src/source_control/open_pull_request_lookups.rs index 51f53dd107..c9b3ef1b54 100644 --- a/apps/server/src/source_control/open_pull_request_lookups.rs +++ b/apps/server/src/source_control/open_pull_request_lookups.rs @@ -12,8 +12,10 @@ //! - Entries are keyed by (worktree path as the caller gave it, provider, //! branch), so a branch change or another repository never reads them. //! - A successful answer, including "no open pull request", is reused for -//! [`OPEN_PULL_REQUEST_LOOKUP_TTL`]. A failed or cancelled lookup is never -//! stored; the next caller asks the forge again. +//! [`OPEN_PULL_REQUEST_LOOKUP_TTL`]. A failed lookup answers with its +//! failure for [`OPEN_PULL_REQUEST_FAILURE_BACKOFF`] and is logged once at +//! `warn`, with the provider only. A cancelled lookup (its last caller left, +//! or shutdown) is never stored; the next caller asks the forge again. //! - Concurrent callers for one key share one in-flight lookup. It runs in its //! own task and holds one lease per waiting caller, so one caller leaving (a //! stream replacing its enrichment, a cancelled RPC) does not cancel the @@ -24,6 +26,9 @@ //! - At most [`OPEN_PULL_REQUEST_LOOKUP_CAPACITY`] entries are held. When full, //! expired and abandoned entries go first, then the entry closest to //! expiring; a lookup with no room still runs, uncoalesced and unstored. +//! - [`OpenPullRequestLookups::shutdown`] (quiesce step `pull-request-lookups`) +//! cancels every flight, with or without an entry, waits until each has +//! ended, and answers every later caller as cancelled. use std::{ collections::HashMap, @@ -34,13 +39,15 @@ use std::{ }; use tokio::{sync::watch, time::Instant}; -use tokio_util::sync::CancellationToken; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; use super::{ProviderKind, ResolvedPullRequest, SourceControlProviderError}; /// How long a successful lookup answers later status reads. Short, because /// pull requests also change outside BiBCode (merged or closed on the web). pub(crate) const OPEN_PULL_REQUEST_LOOKUP_TTL: Duration = Duration::from_secs(10); +/// How long a failed lookup answers later reads with its failure (D6). +pub(crate) const OPEN_PULL_REQUEST_FAILURE_BACKOFF: Duration = Duration::from_secs(5); /// Upper bound on remembered and in-flight lookups across all worktrees. pub(crate) const OPEN_PULL_REQUEST_LOOKUP_CAPACITY: usize = 64; @@ -55,6 +62,23 @@ pub(crate) struct LookupKey { pub(crate) struct OpenPullRequestLookups { state: Mutex, + /// Parent of every flight's token, so shutdown reaches a flight with no entry. + shutdown: CancellationToken, + /// One token per flight, taken under the state lock at admission. + tasks: TaskTracker, + #[cfg(test)] + admission_gate: Mutex>>, + /// Notified by each flight task once its settlement has run. + #[cfg(test)] + flights_settled: tokio::sync::Notify, +} + +/// Holds an admission after its closed check and before its spawn, the window shutdown must +/// not finish in. +#[cfg(test)] +struct AdmissionGate { + reached: std::sync::mpsc::SyncSender<()>, + release: Mutex>, } struct State { @@ -62,6 +86,8 @@ struct State { capacity: usize, next_flight: u64, entries: HashMap, + /// Set by shutdown; admission checks it under the same lock. + closed: bool, } struct Entry { @@ -81,6 +107,11 @@ enum Slot { lease: Weak, result: watch::Receiver>, }, + /// A lookup that failed, not one that was cancelled. + Failed { + error: SourceControlProviderError, + expires_at: Instant, + }, } /// One per waiting caller; the last one dropped cancels the shared lookup. @@ -95,7 +126,9 @@ impl Drop for FlightLease { } enum Admission { + Closed, Cached(Option), + Failed(SourceControlProviderError), Wait(Arc, watch::Receiver>), } @@ -127,7 +160,14 @@ impl OpenPullRequestLookups { capacity, next_flight: 0, entries: HashMap::new(), + closed: false, }), + shutdown: CancellationToken::new(), + tasks: TaskTracker::new(), + #[cfg(test)] + admission_gate: Mutex::default(), + #[cfg(test)] + flights_settled: tokio::sync::Notify::new(), } } @@ -139,8 +179,8 @@ impl OpenPullRequestLookups { /// The remembered answer for `key`, or the result of the shared lookup /// `load` starts. `load` receives the shared lookup's own cancellation. - /// `None` means this caller was cancelled, or the shared lookup ended - /// without an answer (its task was aborted). + /// `None` means this caller was cancelled, the lookups shut down, or the + /// shared lookup ended without an answer (its task was aborted). pub(crate) async fn resolve( self: &Arc, key: LookupKey, @@ -153,11 +193,14 @@ impl OpenPullRequestLookups { Fut: Future + Send + 'static, { let (_lease, mut result) = match self.admit(key, canonical_cwd, load) { + Admission::Closed => return None, Admission::Cached(value) => return Some(Ok(value)), + Admission::Failed(error) => return Some(Err(error)), Admission::Wait(lease, result) => (lease, result), }; tokio::select! { biased; + () = self.shutdown.cancelled() => None, () = cancellation.cancelled() => None, outcome = result.wait_for(Option::is_some) => { outcome.ok().and_then(|outcome| outcome.clone()) @@ -165,12 +208,57 @@ impl OpenPullRequestLookups { } } + /// Refuses new lookups, cancels every running one, in its cache entry or + /// not, and waits until each flight task has ended. A forge CLI child is + /// stopped by force and reaped inside the loader's awaited runner call, in + /// that task, so joining the task is enough. Admission takes its tracker + /// token under the state lock that sets `closed`, so an admission is either + /// waited for or refused. Safe to call twice. + pub(crate) async fn shutdown(&self) { + { + let mut state = self.lock(); + state.closed = true; + self.shutdown.cancel(); + self.tasks.close(); + } + self.tasks.wait().await; + } + + #[cfg(test)] + fn entry_count_for_test(&self) -> usize { + self.lock().entries.len() + } + + #[cfg(test)] + fn wait_at_admission_gate(&self) { + let gate = self + .admission_gate + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + if let Some(gate) = gate { + let _ = gate.reached.send(()); + let _ = gate + .release + .lock() + .unwrap_or_else(PoisonError::into_inner) + .recv(); + } + } + /// Forgets every lookup for a worktree at or below `path`, matching either - /// the caller's spelling of the path or its canonical form. - pub(crate) fn invalidate(&self, path: &Path) { + /// the caller's spelling of the path or its canonical form. Returns whether + /// it dropped a lookup that callers still wait on, whose answer may now be + /// outdated. + pub(crate) fn invalidate(&self, path: &Path) -> bool { + let mut dropped_a_waited_lookup = false; self.lock().entries.retain(|key, entry| { - !(key.cwd.starts_with(path) || entry.canonical_cwd.starts_with(path)) + let keep = !(key.cwd.starts_with(path) || entry.canonical_cwd.starts_with(path)); + dropped_a_waited_lookup |= !keep + && matches!(&entry.slot, Slot::InFlight { lease, .. } if lease.strong_count() > 0); + keep }); + dropped_a_waited_lookup } fn admit(self: &Arc, key: LookupKey, canonical_cwd: PathBuf, load: F) -> Admission @@ -178,58 +266,85 @@ impl OpenPullRequestLookups { F: FnOnce(CancellationToken) -> Fut, Fut: Future + Send + 'static, { - let mut state = self.lock(); - let now = Instant::now(); - if let Some(entry) = state.entries.get(&key) { - match &entry.slot { - Slot::Fresh { value, expires_at } if *expires_at > now => { - return Admission::Cached(value.clone()); - } - Slot::InFlight { lease, result, .. } => { - if let Some(lease) = lease.upgrade() { - return Admission::Wait(lease, result.clone()); + let (id, task, flight_cancellation, lease, sender, result) = { + let mut state = self.lock(); + if state.closed { + return Admission::Closed; + } + let now = Instant::now(); + if let Some(entry) = state.entries.get(&key) { + match &entry.slot { + Slot::Fresh { value, expires_at } if *expires_at > now => { + return Admission::Cached(value.clone()); } + Slot::InFlight { lease, result, .. } => { + if let Some(lease) = lease.upgrade() { + return Admission::Wait(lease, result.clone()); + } + } + Slot::Failed { error, expires_at } if *expires_at > now => { + return Admission::Failed(error.clone()); + } + Slot::Fresh { .. } | Slot::Failed { .. } => {} } - Slot::Fresh { .. } => {} } - } - state.entries.remove(&key); - state.make_room(now); - - let id = state.next_flight; - state.next_flight = state.next_flight.wrapping_add(1); - let flight_cancellation = CancellationToken::new(); - let lease = Arc::new(FlightLease { - cancellation: flight_cancellation.clone(), - }); - let (sender, result) = watch::channel(None); - if state.entries.len() < state.capacity { - state.entries.insert( - key.clone(), - Entry { - canonical_cwd, - slot: Slot::InFlight { - id, - lease: Arc::downgrade(&lease), - result: result.clone(), + state.entries.remove(&key); + state.make_room(now); + + let id = state.next_flight; + state.next_flight = state.next_flight.wrapping_add(1); + // Registered before the lock is released, whether or not an entry is stored, so + // shutdown waits for this flight once it has seen it admitted. + let task = self.tasks.token(); + let flight_cancellation = self.shutdown.child_token(); + let lease = Arc::new(FlightLease { + cancellation: flight_cancellation.clone(), + }); + let (sender, result) = watch::channel(None); + if state.entries.len() < state.capacity { + state.entries.insert( + key.clone(), + Entry { + canonical_cwd, + slot: Slot::InFlight { + id, + lease: Arc::downgrade(&lease), + result: result.clone(), + }, }, - }, - ); - } - let lookup = load(flight_cancellation); + ); + } + (id, task, flight_cancellation, lease, sender, result) + }; + #[cfg(test)] + self.wait_at_admission_gate(); + let lookup = load(flight_cancellation.clone()); let lookups = Arc::clone(self); tokio::spawn(async move { + let _task = task; let outcome = lookup.await; - lookups.settle(&key, id, &outcome); + // True after the last lease dropped and after shutdown: the outcome is not the + // forge's answer, so it is never remembered as a failure. + let cancelled = flight_cancellation.is_cancelled(); + if outcome.is_err() && !cancelled { + tracing::warn!( + provider = ?key.provider, + "open pull-request lookup failed; answering with the failure for 5 seconds" + ); + } + lookups.settle(&key, id, &outcome, cancelled); + #[cfg(test)] + lookups.flights_settled.notify_one(); let _ = sender.send(Some(outcome)); }); Admission::Wait(lease, result) } - /// Stores a successful answer only while this flight still owns the key; - /// an invalidation or a newer flight since it started means its answer may - /// predate a change. - fn settle(&self, key: &LookupKey, id: u64, outcome: &LookupResult) { + /// Stores a successful answer, or an uncancelled failure for + /// [`OPEN_PULL_REQUEST_FAILURE_BACKOFF`], only while this flight still owns + /// the key; an invalidation or a newer flight since it started means its + /// answer may predate a change. + fn settle(&self, key: &LookupKey, id: u64, outcome: &LookupResult, cancelled: bool) { let mut state = self.lock(); let ttl = state.ttl; let Some(entry) = state.entries.get_mut(key) else { @@ -245,6 +360,12 @@ impl OpenPullRequestLookups { expires_at: Instant::now() + ttl, }; } + Err(error) if !cancelled => { + entry.slot = Slot::Failed { + error: error.clone(), + expires_at: Instant::now() + OPEN_PULL_REQUEST_FAILURE_BACKOFF, + }; + } Err(_) => { state.entries.remove(key); } @@ -262,7 +383,7 @@ impl State { return; } self.entries.retain(|_, entry| match &entry.slot { - Slot::Fresh { expires_at, .. } => *expires_at > now, + Slot::Fresh { expires_at, .. } | Slot::Failed { expires_at, .. } => *expires_at > now, Slot::InFlight { lease, .. } => lease.strong_count() > 0, }); while self.entries.len() >= self.capacity { @@ -270,7 +391,9 @@ impl State { .entries .iter() .filter_map(|(key, entry)| match entry.slot { - Slot::Fresh { expires_at, .. } => Some((expires_at, key)), + Slot::Fresh { expires_at, .. } | Slot::Failed { expires_at, .. } => { + Some((expires_at, key)) + } Slot::InFlight { .. } => None, }) .min_by_key(|(expires_at, _)| *expires_at) @@ -332,6 +455,9 @@ mod tests { release: Arc, gated: bool, answer: LookupResult, + /// How long a cancelled gated lookup takes to end, like a child that exits late. + cleanup: Duration, + ended: Arc, } impl Loader { @@ -342,6 +468,8 @@ mod tests { release: Arc::new(Notify::new()), gated: false, answer, + cleanup: Duration::ZERO, + ended: Arc::default(), } } @@ -356,6 +484,14 @@ mod tests { self.runs.load(Ordering::SeqCst) } + fn cancelled(&self) -> usize { + self.cancelled.load(Ordering::SeqCst) + } + + fn ended(&self) -> usize { + self.ended.load(Ordering::SeqCst) + } + fn load(&self) -> impl FnOnce(CancellationToken) -> BoxedLookup + use<> { let loader = self.clone(); move |flight: CancellationToken| -> BoxedLookup { @@ -365,6 +501,8 @@ mod tests { tokio::select! { () = flight.cancelled() => { loader.cancelled.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(loader.cleanup).await; + loader.ended.fetch_add(1, Ordering::SeqCst); return Err(failure()); } () = loader.release.notified() => {} @@ -399,6 +537,139 @@ mod tests { .expect("loader run deadline"); } + /// Every wait on another task is bounded, so a regression fails instead of hanging. + async fn bounded(future: F) -> F::Output { + tokio::time::timeout(Duration::from_secs(30), future) + .await + .expect("bounded wait") + } + + fn spawn_resolve( + lookups: &Arc, + key: LookupKey, + loader: &Loader, + ) -> tokio::task::JoinHandle> { + let (lookups, loader) = (Arc::clone(lookups), loader.clone()); + tokio::spawn(async move { resolve(&lookups, key, &loader).await }) + } + + #[tokio::test(start_paused = true)] + async fn shutdown_waits_for_a_lookup_that_ends_late() { + let lookups = Arc::new(OpenPullRequestLookups::default()); + let loader = Loader { + cleanup: Duration::from_millis(200), + ..Loader::gated(Ok(None)) + }; + let waiter = spawn_resolve(&lookups, key("/repo", "main"), &loader); + wait_for_runs(&loader, 1).await; + let start = Instant::now(); + bounded(lookups.shutdown()).await; + assert_eq!( + loader.ended(), + 1, + "shutdown returned before the lookup ended" + ); + assert!(start.elapsed() >= Duration::from_millis(200)); + assert_eq!(bounded(waiter).await.unwrap(), None); + } + + #[tokio::test(start_paused = true)] + async fn shutdown_stops_an_invalidated_flight() { + let lookups = Arc::new(OpenPullRequestLookups::default()); + let loader = Loader::gated(Ok(None)); + let waiter = spawn_resolve(&lookups, key("/repo", "main"), &loader); + wait_for_runs(&loader, 1).await; + lookups.invalidate(Path::new("/repo")); + assert_eq!(lookups.entry_count_for_test(), 0); + bounded(lookups.shutdown()).await; + assert_eq!(loader.cancelled(), 1); + assert_eq!(loader.ended(), 1); + assert_eq!(bounded(waiter).await.unwrap(), None); + } + + #[tokio::test(start_paused = true)] + async fn shutdown_stops_a_flight_that_had_no_cache_room() { + let lookups = Arc::new(OpenPullRequestLookups::with_limits( + OPEN_PULL_REQUEST_LOOKUP_TTL, + 0, + )); + let loader = Loader::gated(Ok(None)); + let waiter = spawn_resolve(&lookups, key("/repo", "main"), &loader); + wait_for_runs(&loader, 1).await; + bounded(lookups.shutdown()).await; + assert_eq!(loader.cancelled(), 1); + assert_eq!(loader.ended(), 1); + assert_eq!(lookups.entry_count_for_test(), 0); + assert_eq!(bounded(waiter).await.unwrap(), None); + } + + #[tokio::test(start_paused = true)] + async fn shutdown_ends_a_retained_waiter_as_cancelled() { + let lookups = Arc::new(OpenPullRequestLookups::default()); + let loader = Loader::gated(Ok(Some(pull_request("main")))); + // The waiter's own token is never cancelled, so it keeps its lease. + let waiter = spawn_resolve(&lookups, key("/repo", "main"), &loader); + wait_for_runs(&loader, 1).await; + bounded(lookups.shutdown()).await; + assert_eq!(bounded(waiter).await.unwrap(), None); + assert_eq!(loader.cancelled(), 1); + } + + #[tokio::test(start_paused = true)] + async fn shutdown_refuses_a_new_lookup() { + let lookups = Arc::new(OpenPullRequestLookups::default()); + let warm = Loader::answering(Ok(Some(pull_request("main")))); + resolve(&lookups, key("/repo", "main"), &warm).await; + bounded(lookups.shutdown()).await; + + let loader = Loader::answering(Ok(None)); + // A remembered answer is refused too: the closed check comes first. + assert_eq!(resolve(&lookups, key("/repo", "main"), &loader).await, None); + assert_eq!( + resolve(&lookups, key("/repo", "other"), &loader).await, + None + ); + assert_eq!(loader.runs(), 0); + tokio::time::timeout(Duration::from_millis(1), lookups.shutdown()) + .await + .expect("a second shutdown returns at once"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn shutdown_cannot_finish_between_an_admissions_check_and_its_spawn() { + let lookups = Arc::new(OpenPullRequestLookups::default()); + let (reached, reached_signal) = std::sync::mpsc::sync_channel(1); + let (release_signal, release) = std::sync::mpsc::channel(); + *lookups + .admission_gate + .lock() + .unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(AdmissionGate { + reached, + release: Mutex::new(release), + })); + let loader = Loader::gated(Ok(None)); + let mut admitted = spawn_resolve(&lookups, key("/repo", "main"), &loader); + reached_signal + .recv_timeout(Duration::from_secs(30)) + .expect("the admission reached the gate"); + let mut shutdown = tokio::spawn({ + let lookups = Arc::clone(&lookups); + async move { lookups.shutdown().await } + }); + assert!( + tokio::time::timeout(Duration::from_millis(200), &mut shutdown) + .await + .is_err(), + "shutdown finished while an admission was between its check and its spawn" + ); + release_signal.send(()).expect("the gate is waiting"); + bounded(&mut shutdown).await.unwrap(); + assert_eq!(loader.runs(), 1); + assert_eq!(loader.cancelled(), 1); + assert_eq!(loader.ended(), 1); + assert_eq!(bounded(&mut admitted).await.unwrap(), None); + } + #[tokio::test] async fn answers_including_no_pull_request_are_reused_until_the_ttl_ends() { let lookups = Arc::new(OpenPullRequestLookups::with_limits( @@ -432,30 +703,167 @@ mod tests { assert_eq!(loader.runs(), 4); } - #[tokio::test] - async fn failures_are_shared_with_waiters_but_never_stored() { + #[tokio::test(start_paused = true)] + async fn failures_are_shared_with_waiters_and_remembered_for_five_seconds() { let lookups = Arc::new(OpenPullRequestLookups::default()); let loader = Loader::gated(Err(failure())); - let first = tokio::spawn({ - let lookups = Arc::clone(&lookups); - let loader = loader.clone(); - async move { resolve(&lookups, key("/repo", "main"), &loader).await } - }); + let first = spawn_resolve(&lookups, key("/repo", "main"), &loader); wait_for_runs(&loader, 1).await; - let second = tokio::spawn({ - let lookups = Arc::clone(&lookups); - let loader = loader.clone(); - async move { resolve(&lookups, key("/repo", "main"), &loader).await } - }); - tokio::time::sleep(Duration::from_millis(50)).await; + let second = spawn_resolve(&lookups, key("/repo", "main"), &loader); + tokio::time::advance(Duration::from_millis(50)).await; loader.release.notify_one(); - assert_eq!(first.await.unwrap(), Some(Err(failure()))); - assert_eq!(second.await.unwrap(), Some(Err(failure()))); + assert_eq!(bounded(first).await.unwrap(), Some(Err(failure()))); + assert_eq!(bounded(second).await.unwrap(), Some(Err(failure()))); assert_eq!(loader.runs(), 1, "the waiter joined the running lookup"); let recovered = Loader::answering(Ok(None)); + assert_eq!( + resolve(&lookups, key("/repo", "main"), &recovered).await, + Some(Err(failure())) + ); + assert_eq!(recovered.runs(), 0, "the failure answers from memory"); + tokio::time::advance(OPEN_PULL_REQUEST_FAILURE_BACKOFF).await; resolve(&lookups, key("/repo", "main"), &recovered).await; - assert_eq!(recovered.runs(), 1, "a failure must not be remembered"); + assert_eq!( + recovered.runs(), + 1, + "an expired failure asks the forge again" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_failed_lookup_answers_from_memory_for_five_seconds() { + let lookups = Arc::new(OpenPullRequestLookups::default()); + let loader = Loader::answering(Err(failure())); + assert_eq!( + resolve(&lookups, key("/repo", "main"), &loader).await, + Some(Err(failure())) + ); + assert_eq!(loader.runs(), 1); + tokio::time::advance(Duration::from_secs(4)).await; + assert_eq!( + resolve(&lookups, key("/repo", "main"), &loader).await, + Some(Err(failure())) + ); + assert_eq!(loader.runs(), 1); + tokio::time::advance(Duration::from_millis(1_001)).await; + resolve(&lookups, key("/repo", "main"), &loader).await; + assert_eq!(loader.runs(), 2); + } + + /// Real clock, so a missing settlement signal ends in a timeout, not a hang. + #[tokio::test] + async fn a_cancelled_lookup_is_not_remembered_as_failed() { + let lookups = Arc::new(OpenPullRequestLookups::default()); + let loader = Loader::gated(Err(failure())); + let leaving = CancellationToken::new(); + let waiter = tokio::spawn({ + let (lookups, loader, leaving) = + (Arc::clone(&lookups), loader.clone(), leaving.clone()); + async move { + lookups + .resolve( + key("/repo", "main"), + PathBuf::from("/repo"), + &leaving, + loader.load(), + ) + .await + } + }); + wait_for_runs(&loader, 1).await; + leaving.cancel(); + assert_eq!(bounded(waiter).await.unwrap(), None); + bounded(lookups.flights_settled.notified()).await; + assert_eq!(loader.cancelled(), 1); + assert_eq!(lookups.entry_count_for_test(), 0); + loader.release.notify_one(); + resolve(&lookups, key("/repo", "main"), &loader).await; + assert_eq!( + loader.runs(), + 2, + "a cancelled lookup must not answer from memory" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_shutdown_flight_is_not_remembered_as_failed() { + let lookups = Arc::new(OpenPullRequestLookups::default()); + let loader = Loader::gated(Err(failure())); + let waiter = spawn_resolve(&lookups, key("/repo", "main"), &loader); + wait_for_runs(&loader, 1).await; + bounded(lookups.shutdown()).await; + assert_eq!(loader.cancelled(), 1); + assert_eq!(lookups.entry_count_for_test(), 0); + assert_eq!(bounded(waiter).await.unwrap(), None); + } + + #[tokio::test(start_paused = true)] + async fn an_invalidation_clears_a_remembered_failure() { + let lookups = Arc::new(OpenPullRequestLookups::default()); + let loader = Loader::answering(Err(failure())); + resolve(&lookups, key("/repo", "main"), &loader).await; + lookups.invalidate(Path::new("/repo")); + resolve(&lookups, key("/repo", "main"), &loader).await; + assert_eq!(loader.runs(), 2); + } + + #[derive(Clone, Default)] + struct CapturedLog(Arc>>); + + impl std::io::Write for CapturedLog { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .lock() + .unwrap_or_else(PoisonError::into_inner) + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + /// In a process of its own, so no sibling test can turn the callsite off for this capture. + #[tokio::test] + async fn a_failed_lookup_warns_once_without_the_remote_address() { + let Some(child) = crate::test_support::in_own_process( + "source_control::open_pull_request_lookups::tests::a_failed_lookup_warns_once_without_the_remote_address", + ) else { + return; + }; + let log = CapturedLog::default(); + let _logging = tracing::subscriber::set_default( + tracing_subscriber::fmt() + .with_ansi(false) + .with_max_level(tracing::Level::WARN) + .with_writer({ + let log = log.clone(); + move || log.clone() + }) + .finish(), + ); + let lookups = Arc::new(OpenPullRequestLookups::default()); + let loader = Loader::answering(Err(SourceControlProviderError { + cwd: "/work/sample-project".into(), + detail: "could not reach https://gitlab.example.test/example-group/sample-project" + .into(), + ..failure() + })); + for _ in 0..2 { + resolve(&lookups, key("/work/sample-project", "main"), &loader).await; + } + assert_eq!(loader.runs(), 1); + let text = String::from_utf8(log.0.lock().unwrap().clone()).expect("UTF-8 log"); + assert_eq!( + text.lines().filter(|line| line.contains("WARN")).count(), + 1, + "{text}" + ); + assert!(!text.contains("gitlab.example.test"), "{text}"); + assert!(!text.contains("sample-project"), "{text}"); + child.complete(); } #[tokio::test] @@ -536,7 +944,10 @@ mod tests { let lookups = Arc::new(OpenPullRequestLookups::default()); let loader = Loader::answering(Ok(None)); resolve(&lookups, key("/private/var/repo", "main"), &loader).await; - lookups.invalidate(Path::new("/private/var/repo")); + assert!( + !lookups.invalidate(Path::new("/private/var/repo")), + "a stored answer is not an awaited lookup" + ); resolve(&lookups, key("/private/var/repo", "main"), &loader).await; assert_eq!(loader.runs(), 2, "invalidation drops the stored answer"); @@ -571,7 +982,10 @@ mod tests { async move { resolve(&lookups, key("/repo", "main"), &stale).await } }); wait_for_runs(&stale, 1).await; - lookups.invalidate(Path::new("/repo")); + assert!( + lookups.invalidate(Path::new("/repo")), + "an awaited lookup was dropped" + ); stale.release.notify_one(); assert_eq!(waiter.await.unwrap(), Some(Ok(None))); let fresh = Loader::answering(Ok(Some(pull_request("main")))); diff --git a/apps/server/src/source_control/pull_request.rs b/apps/server/src/source_control/pull_request.rs index 5e50680c91..a66c03a234 100644 --- a/apps/server/src/source_control/pull_request.rs +++ b/apps/server/src/source_control/pull_request.rs @@ -606,7 +606,7 @@ impl PullRequestService { /// [`Self::resolve_current_optional`] for VCS status enrichment: a /// successful answer (including no open pull request) is reused for a few /// seconds per (worktree, provider, branch), concurrent callers share one - /// provider run, and failures are never reused. Callers that must see the + /// provider run, and a failure answers for 5 seconds. Callers that must see the /// hosting service's current answer, such as the existence check before /// creating a pull request, use the uncached method. pub async fn resolve_current_optional_shared( @@ -649,9 +649,15 @@ impl PullRequestService { /// Forgets the shared open pull-request lookups for the worktree at or /// below `path`, after something that can change the hosting service's /// answer: a push, a created pull request, a finished Git mutation, or a - /// reported local change such as a terminal command exiting. - pub fn invalidate_open_pull_request_lookups(&self, path: &std::path::Path) { - self.open_lookups.invalidate(path); + /// reported local change such as a terminal command exiting. Returns + /// whether a lookup still awaited was dropped, so its answer may be outdated. + pub fn invalidate_open_pull_request_lookups(&self, path: &std::path::Path) -> bool { + self.open_lookups.invalidate(path) + } + + /// Ends every shared open pull-request lookup and refuses new ones, for quiesce (R26). + pub async fn shutdown_open_pull_request_lookups(&self) { + self.open_lookups.shutdown().await; } pub(crate) fn current_provider_command( @@ -3344,6 +3350,65 @@ esac let _ = server.task.await; } + /// R26: the quiesce step ends a shared Bitbucket lookup whose server never answers at once, + /// not at the 30-second request timeout. + #[tokio::test] + async fn pull_request_lookup_shutdown_ends_a_stalled_bitbucket_request() { + let _fixture_permit = acquire_pull_request_fixture().await; + + let repository = bitbucket_repository().await; + let mut server = spawn_stalled_http_server().await; + let service = bitbucket_service_for_repository( + &server.base_url, + Some(BitbucketCredentials::Bearer("test-token".into())), + &repository, + ); + let waiter = tokio::spawn({ + let (service, cwd) = (service.clone(), repository.root().to_path_buf()); + async move { + // The waiter's own token is never cancelled. + service + .resolve_current_optional_shared( + ResolvePullRequestInput { + cwd, + provider: ProviderKind::Bitbucket, + reference: "feature/native".into(), + }, + &CancellationToken::new(), + ) + .await + } + }); + tokio::time::timeout(Duration::from_secs(30), server.requests.recv()) + .await + .expect("the request reached the stub") + .expect("in-flight request"); + + let started = tokio::time::Instant::now(); + tokio::time::timeout( + Duration::from_secs(30), + service.shutdown_open_pull_request_lookups(), + ) + .await + .expect("shutdown returns"); + assert!( + started.elapsed() < Duration::from_secs(5), + "shutdown waited {:?} for the stalled request", + started.elapsed() + ); + let error = tokio::time::timeout(Duration::from_secs(5), waiter) + .await + .expect("the waiter returned") + .expect("waiter task") + .expect_err("the lookup was cancelled"); + assert_eq!( + error.detail.as_ref(), + "The pull-request lookup was cancelled." + ); + server.task.abort(); + let _ = server.task.await; + } + #[tokio::test] async fn bitbucket_cancellation_stops_a_stalled_response_body_read() { let _fixture_permit = acquire_pull_request_fixture().await; diff --git a/apps/server/src/webhooks/store.rs b/apps/server/src/webhooks/store.rs index e30018c926..f576534b03 100644 --- a/apps/server/src/webhooks/store.rs +++ b/apps/server/src/webhooks/store.rs @@ -933,9 +933,9 @@ impl Store { /// Deletes finished deliveries older than 7 days, and beyond the newest 1,000 of each /// endpoint. Pending deliveries are never pruned. pub(crate) async fn prune(&self) -> Result { - let cutoff = (time::OffsetDateTime::now_utc() - time::Duration::days(KEPT_DAYS)) - .format(&time::format_description::well_known::Rfc3339) - .unwrap_or_default(); + let cutoff = crate::persistence::format_timestamp( + time::OffsetDateTime::now_utc() - time::Duration::days(KEPT_DAYS), + ); self.database .call(move |connection| { let transaction = connection.transaction()?; diff --git a/apps/server/tests/profiler_runtime.rs b/apps/server/tests/profiler_runtime.rs index c959194ac4..99c193ba8b 100644 --- a/apps/server/tests/profiler_runtime.rs +++ b/apps/server/tests/profiler_runtime.rs @@ -91,6 +91,77 @@ async fn profiler_runtime_scenarios_share_the_process_global_handle_in_sequence( the_sampler_records_the_server_process().await; turning_the_profiler_off_writes_the_open_minute().await; a_client_batch_is_joined_to_the_server_timing().await; + two_identical_render_errors_are_two_events_and_one_finding().await; +} + +/// R51: a render error the client's error screen caught is one `client.render-error` event +/// each time, and repeats count in one finding. +async fn two_identical_render_errors_are_two_events_and_one_finding() { + let dir = tempfile::tempdir().unwrap(); + let (_tx, rx) = watch::channel(true); + let runtime = ProfilerRuntime::start(dir.path(), rx, vec![]); + tokio::time::sleep(Duration::from_millis(50)).await; + let render_error = serde_json::json!({ + "errorType": "Error", + "reactErrorCode": 185, + "componentNames": ["GitManagerBranchDropdown", "Popover"], + "routePattern": "/_chat/$environmentId/$threadId", + "fingerprint": "0a1b2c3d", + "message": "Maximum update depth exceeded in thread-secret", + }); + // A client id of its own: the batch pacer is process-wide. + let batch = serde_json::json!({ + "clientId": "render-errors", + "capabilities": ["heartbeat"], + "windowMs": 10_000, + "records": [], + "histograms": [], + "stalls": { "count": 0, "totalMs": 0, "maxMs": 0 }, + "interactions": { "count": 0, "maxMs": 0 }, + "jsHeapBytes": null, + "renderErrors": [render_error.clone(), render_error], + }); + assert_eq!( + client::ingest_client_batch(batch, "env-1"), + Ok(serde_json::json!({ "accepted": true })) + ); + let findings = runtime.status()["findings"].clone(); + let render_findings: Vec<&serde_json::Value> = findings + .as_array() + .unwrap() + .iter() + .filter(|finding| finding["kind"] == "client.render-error") + .collect(); + assert_eq!(render_findings.len(), 1, "{findings}"); + assert_eq!(render_findings[0]["count"], 2); + assert_eq!(render_findings[0]["severity"], "error"); + assert_eq!(render_findings[0]["source"], "client"); + + for _ in 0..200 { + if runtime.status()["eventCount"].as_u64() >= Some(3) { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + runtime.shutdown().await; + let lines: Vec = std::fs::read_dir(profiler::profiler_dir(dir.path())) + .unwrap() + .filter_map(|entry| std::fs::read_to_string(entry.unwrap().path()).ok()) + .flat_map(|text| text.lines().map(str::to_owned).collect::>()) + .filter_map(|line| serde_json::from_str(&line).ok()) + .collect(); + let events: Vec<&serde_json::Value> = lines + .iter() + .filter(|line| line["kind"] == "client.render-error" && line["source"] != "client") + .collect(); + assert_eq!(events.len(), 2, "two events"); + for event in &events { + assert_eq!(event["source"], "client:render-errors"); + assert_eq!(event["reactErrorCode"], 185); + let text = event.to_string(); + assert!(!text.contains("Maximum update depth"), "{text}"); + assert!(!text.contains("env-1"), "{text}"); + } } async fn rapid_toggles_never_run_two_detector_sets() { diff --git a/apps/server/tests/profiler_worktree_audit.rs b/apps/server/tests/profiler_worktree_audit.rs index e611754236..7b9e4ace1e 100644 --- a/apps/server/tests/profiler_worktree_audit.rs +++ b/apps/server/tests/profiler_worktree_audit.rs @@ -31,7 +31,10 @@ use bibcode_server::{ use futures_util::SinkExt; use serde_json::{Value, json}; use tempfile::TempDir; -use tokio::{sync::watch, time::timeout}; +use tokio::{ + sync::{Semaphore, watch}, + time::timeout, +}; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message}; #[path = "support/isolated_git_config.rs"] @@ -129,7 +132,7 @@ async fn the_audit_and_removal_outcomes_reach_the_profiler() { fs::remove_dir(&other_orphan).expect("remove one orphan"); scan_once(&sources, &processes).await; let next = runtime.status()["findings"].as_array().unwrap().clone(); - assert_eq!(next.len(), 2); + assert_eq!(next.len(), 2, "{:?}", finding_kinds(&runtime)); for finding in next { let expected = if finding["id"] == remaining_id { 3 } else { 2 }; assert_eq!( @@ -220,7 +223,8 @@ async fn the_audit_and_removal_outcomes_reach_the_profiler() { assert_eq!( removal_failures(&runtime), 2, - "a drain timeout is its own finding" + "a drain timeout is its own finding: {:?}", + removal_failure_summaries(&runtime) ); // A client that interrupts the removal once the server-owned operation is waiting for @@ -230,16 +234,22 @@ async fn the_audit_and_removal_outcomes_reach_the_profiler() { .acquire_admission(&thread_id, [fixture.worktree.as_path()]) .await .expect("admission"); + // Forget the permit of the undrained removal, so the next one is this removal's. + let earlier = fixture.drain_entered.available_permits(); + if let Ok(permits) = fixture + .drain_entered + .try_acquire_many(u32::try_from(earlier).expect("permit count")) + { + permits.forget(); + } remove(&mut fixture, &thread_id, "remove-interrupted", true, "6").await; - timeout(RPC_DEADLINE, async { - while fixture.availability.guard_thread(&thread_id).await.is_ok() { - tokio::time::sleep(Duration::from_millis(5)).await; - } - }) - .await - .expect("the removal holds its removing guard"); - // Past the plan, which takes milliseconds, and into the drain, which takes 5 s. - tokio::time::sleep(Duration::from_secs(1)).await; + // Interrupt inside the drain, whatever the plan before it takes: waiting on time instead + // lets a slow plan see the interrupt and fail with its own reason. + timeout(RPC_DEADLINE, fixture.drain_entered.acquire()) + .await + .expect("the removal reaches the admission drain") + .expect("drain signal") + .forget(); send( fixture.socket(), json!({ "_tag": "Interrupt", "requestId": "7" }), @@ -269,7 +279,8 @@ async fn the_audit_and_removal_outcomes_reach_the_profiler() { assert_eq!( removal_failures(&runtime), 2, - "an interrupted removal is not a finding" + "an interrupted removal is not a finding: {:?}", + removal_failure_summaries(&runtime) ); // An exact retry replays the accepted result: not a new outcome. request( @@ -332,6 +343,72 @@ async fn the_audit_and_removal_outcomes_reach_the_profiler() { ); } + // A creation whose thread dispatch is refused rolls its worktree back. + git( + &fixture.git_config, + &fixture.main, + &["branch", "feature/dup-a"], + ); + fixture + .engine + .dispatch( + serde_json::from_value(json!({ + "type":"thread.create", + "commandId":"dup-a-create", + "threadId":"dup-a-thread", + "projectId":"project-1", + "title":"Existing", + "modelSelection":{"instanceId":"codex","model":"gpt-5"}, + "runtimeMode":"full-access", + "createdAt":"2026-10-09T00:00:01Z" + })) + .expect("thread command"), + ) + .await + .expect("existing thread"); + let records_before = fixture.creation_records().await; + request( + fixture.socket(), + "800", + "worktree.createManaged", + managed_create_payload("dup-a", "dup-a-thread", "feature/dup-a"), + ) + .await; + assert_eq!( + failure_reason(fixture.socket(), "800").await, + "orchestration-failed" + ); + assert_eq!( + fixture.creation_records().await, + records_before, + "the rolled-back creation keeps no record" + ); + + // A creation whose record cannot be written rolls its worktree back. Last: it drops the + // records table, so no scan or record read follows. + git( + &fixture.git_config, + &fixture.main, + &["branch", "feature/dup-b"], + ); + fixture + .repositories + .database() + .call(|connection| { + connection.execute_batch("DROP TABLE worktree_creation_records")?; + Ok(()) + }) + .await + .expect("records table removed"); + request( + fixture.socket(), + "801", + "worktree.createManaged", + managed_create_payload("dup-b", "dup-b-thread", "feature/dup-b"), + ) + .await; + assert_eq!(failure_reason(fixture.socket(), "801").await, "internal"); + let events = shutdown_and_read_events(runtime, state.path()).await; let removals: Vec<_> = events .iter() @@ -349,9 +426,27 @@ async fn the_audit_and_removal_outcomes_reach_the_profiler() { .collect(); assert_eq!( removals.len(), - 4, - "the replay is not recorded: {removals:?}" + 6, + "four removals and two creation rollbacks; the replay is not recorded: {removals:?}" ); + // One per rollback arm, so removing either audit call fails this count. + assert_eq!( + removals + .iter() + .filter(|&&(mode, ..)| mode == "create-rollback") + .copied() + .collect::>(), + [ + ("create-rollback", true, None, false, false), + ("create-rollback", true, None, false, false), + ], + "{removals:?}" + ); + let rollback_ids: Vec<&Value> = events + .iter() + .filter(|event| event["kind"] == "worktree.removal" && event["mode"] == "create-rollback") + .map(|event| &event["eventId"]) + .collect(); for expected in [ ( "delete-git-worktree", @@ -389,6 +484,10 @@ async fn the_audit_and_removal_outcomes_reach_the_profiler() { .map(|finding| &finding["evidence"]["eventIds"][0]) .collect(); assert!(!cited.is_empty()); + assert!( + cited.iter().all(|id| !rollback_ids.contains(id)), + "a successful rollback raises no finding: {cited:?}" + ); assert!( cited .iter() @@ -446,6 +545,26 @@ fn removal_failures(runtime: &ProfilerRuntime) -> usize { .count() } +/// Each `worktree.removal-failed` finding as `id severity count: summary`. The summary names +/// the mode and the reason token (or `admission-drain-timeout`), never a path. +fn removal_failure_summaries(runtime: &ProfilerRuntime) -> Vec { + runtime.status()["findings"] + .as_array() + .into_iter() + .flatten() + .filter(|finding| finding["kind"] == "worktree.removal-failed") + .map(|finding| { + format!( + "{} {} x{}: {}", + finding["id"].as_str().unwrap_or_default(), + finding["severity"].as_str().unwrap_or_default(), + finding["count"], + finding["summary"].as_str().unwrap_or_default() + ) + }) + .collect() +} + fn finding_kinds(runtime: &ProfilerRuntime) -> Vec { runtime.status()["findings"] .as_array() @@ -501,6 +620,24 @@ async fn remove( .await; } +/// A `worktree.createManaged` payload for `ref_name`, with the thread defaults `adopt` uses. +fn managed_create_payload(command_id: &str, thread_id: &str, ref_name: &str) -> Value { + json!({ + "commandId":command_id, + "projectId":"project-1", + "threadId":thread_id, + "title":"Managed", + "refName":ref_name, + "newRefName":null, + "baseRefName":null, + "threadDefaults":{ + "modelSelection":{"instanceId":"codex","model":"gpt-5"}, + "runtimeMode":"full-access", + "interactionMode":"default" + } + }) +} + async fn adopt(fixture: &mut Fixture) -> String { request( fixture.socket(), @@ -552,6 +689,8 @@ struct Fixture { repositories: Repositories, catalog: WorktreeCatalogService, availability: WorkspaceAvailabilityRegistry, + /// One permit each time a removal starts waiting for admitted work. + drain_entered: Arc, operations: WorktreeCatalogOperationRuntime, engine: OrchestrationEngine, handle: Option, @@ -617,7 +756,9 @@ impl Fixture { Arc::new(GitRepository::default()), availability.clone(), ); - let services = WorktreeCatalogRpcServices::new(catalog.clone(), engine.clone()); + let drain_entered = Arc::new(Semaphore::new(0)); + let services = WorktreeCatalogRpcServices::new(catalog.clone(), engine.clone()) + .with_removal_drain_signal_for_integration_test(Arc::clone(&drain_entered)); let operations = services.operation_runtime(); let mut registry = RpcRegistry::empty(); register_worktree_catalog_rpc(&mut registry, services); @@ -640,6 +781,7 @@ impl Fixture { repositories, catalog, availability, + drain_entered, operations, engine, handle: Some(handle), diff --git a/apps/server/tests/repositories.rs b/apps/server/tests/repositories.rs index 1e828bd6d5..b98e4cc9e6 100644 --- a/apps/server/tests/repositories.rs +++ b/apps/server/tests/repositories.rs @@ -489,7 +489,7 @@ fn public_repository_api_inventory_is_explicit() { "complete_worktree_removal_receipt", "list_incomplete_worktree_removal_paths", "record_worktree_creation", - "delete_worktree_creation_record", + "worktree_creation_record_id", "reap_worktree_creation_record", "list_worktree_creation_records", "register_mcp_oauth_client", diff --git a/apps/web/package.json b/apps/web/package.json index e85d87cae3..09e2e4358b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -41,6 +41,7 @@ "@tiptap/pm": "3.31.4", "@tiptap/starter-kit": "3.31.4", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index 73082c71bb..92591ddcad 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -2,13 +2,16 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { acquireBrowserSurface, + acquireBrowserSurfaceOcclusion, + isBrowserSurfaceOccluded, resolveBrowserSurfacePanelRect, useBrowserSurfaceStore, + type BrowserSurfaceRect, } from "./browserSurfaceStore"; describe("browserSurfaceStore", () => { beforeEach(() => { - useBrowserSurfaceStore.setState({ byTabId: {} }); + useBrowserSurfaceStore.setState({ byTabId: {}, occlusionOwners: new Map() }); }); it("tracks content dimensions for a browser that has never been visible", () => { @@ -198,4 +201,53 @@ describe("browserSurfaceStore", () => { owner: null, }); }); + + describe("occlusion by rectangle", () => { + const surface = { x: 0, y: 0, width: 400, height: 300 }; + const owners = (...rects: Array) => + new Map(rects.map((rect) => [Symbol("owner"), rect] as const)); + + it("is occluded by a lease without a rectangle", () => { + expect(isBrowserSurfaceOccluded(surface, owners(null))).toBe(true); + }); + + it("is occluded by an overlapping rectangle", () => { + expect( + isBrowserSurfaceOccluded(surface, owners({ x: 390, y: 290, width: 50, height: 50 })), + ).toBe(true); + }); + + it("is not occluded by a disjoint rectangle or one that only touches an edge", () => { + expect(isBrowserSurfaceOccluded(surface, owners())).toBe(false); + expect( + isBrowserSurfaceOccluded(surface, owners({ x: 500, y: 0, width: 100, height: 100 })), + ).toBe(false); + expect( + isBrowserSurfaceOccluded(surface, owners({ x: 400, y: 0, width: 100, height: 100 })), + ).toBe(false); + expect( + isBrowserSurfaceOccluded(surface, owners({ x: 0, y: 300, width: 100, height: 100 })), + ).toBe(false); + }); + + it("replaces a lease's rectangle on update and removes it on release", () => { + const first = { x: 500, y: 0, width: 100, height: 100 }; + const moved = { x: 100, y: 100, width: 50, height: 50 }; + const lease = acquireBrowserSurfaceOcclusion(first); + expect([...useBrowserSurfaceStore.getState().occlusionOwners.values()]).toEqual([first]); + + lease.update(moved); + expect([...useBrowserSurfaceStore.getState().occlusionOwners.values()]).toEqual([moved]); + + lease.release(); + lease.update(first); + expect(useBrowserSurfaceStore.getState().occlusionOwners.size).toBe(0); + }); + + it("treats a lease taken without a rectangle as covering everything", () => { + const lease = acquireBrowserSurfaceOcclusion(); + expect([...useBrowserSurfaceStore.getState().occlusionOwners.values()]).toEqual([null]); + lease.release(); + }); + }); }); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index 11287562c9..659ad5df97 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -27,7 +27,8 @@ export interface BrowserSurfaceContentPresentation { interface BrowserSurfaceStoreState { readonly byTabId: Record; - readonly occlusionOwners: ReadonlySet; + /** Occlusion leases by owner; a null rectangle covers every surface. */ + readonly occlusionOwners: ReadonlyMap; readonly claim: (tabId: string, owner: symbol) => void; readonly present: ( tabId: string, @@ -45,9 +46,26 @@ export interface BrowserSurfaceLease { } export interface BrowserSurfaceOcclusionLease { + readonly update: (rect: BrowserSurfaceRect | null) => void; readonly release: () => void; } +const rectsOverlap = (left: BrowserSurfaceRect, right: BrowserSurfaceRect): boolean => + left.x < right.x + right.width && + right.x < left.x + left.width && + left.y < right.y + right.height && + right.y < left.y + left.height; + +export function isBrowserSurfaceOccluded( + rect: BrowserSurfaceRect, + owners: ReadonlyMap, +): boolean { + for (const owner of owners.values()) { + if (owner === null || rectsOverlap(rect, owner)) return true; + } + return false; +} + export function resolveBrowserSurfacePanelRect( byTabId: Readonly>, tabId: string, @@ -77,7 +95,7 @@ const rectEquals = (left: BrowserSurfaceRect | null, right: BrowserSurfaceRect): export const useBrowserSurfaceStore = create()((set) => ({ byTabId: {}, - occlusionOwners: new Set(), + occlusionOwners: new Map(), claim: (tabId, owner) => set((state) => { const current = state.byTabId[tabId]; @@ -157,20 +175,29 @@ export const useBrowserSurfaceStore = create()((set) = }), })); -export function acquireBrowserSurfaceOcclusion(): BrowserSurfaceOcclusionLease { +export function acquireBrowserSurfaceOcclusion( + rect: BrowserSurfaceRect | null = null, +): BrowserSurfaceOcclusionLease { const owner = Symbol("browser-surface-occlusion"); let released = false; useBrowserSurfaceStore.setState((state) => ({ - occlusionOwners: new Set(state.occlusionOwners).add(owner), + occlusionOwners: new Map(state.occlusionOwners).set(owner, rect), })); return { + update: (next) => { + if (released) return; + useBrowserSurfaceStore.setState((state) => { + if (!state.occlusionOwners.has(owner)) return state; + return { occlusionOwners: new Map(state.occlusionOwners).set(owner, next) }; + }); + }, release: () => { if (released) return; released = true; useBrowserSurfaceStore.setState((state) => { if (!state.occlusionOwners.has(owner)) return state; - const occlusionOwners = new Set(state.occlusionOwners); + const occlusionOwners = new Map(state.occlusionOwners); occlusionOwners.delete(owner); return { occlusionOwners }; }); diff --git a/apps/web/src/browser/browserSurfaceSync.test.ts b/apps/web/src/browser/browserSurfaceSync.test.ts index 1460d59027..32b12ae14a 100644 --- a/apps/web/src/browser/browserSurfaceSync.test.ts +++ b/apps/web/src/browser/browserSurfaceSync.test.ts @@ -32,7 +32,7 @@ describe("browserSurfaceSync", () => { }; beforeEach(() => { - useBrowserSurfaceStore.setState({ byTabId: {}, occlusionOwners: new Set() } as never); + useBrowserSurfaceStore.setState({ byTabId: {}, occlusionOwners: new Map() }); }); afterEach(() => { @@ -155,6 +155,34 @@ describe("browserSurfaceSync", () => { stop(); }); + it("hides a surface only while a rectangle lease overlaps it", async () => { + const setBounds = vi.fn().mockResolvedValue(undefined); + const stop = startSync(setBounds); + const surface = acquireBrowserSurface("sync-rect-occlusion"); + const rect = { x: 0, y: 0, width: 400, height: 300 }; + surface.present(rect, true); + + const popup = acquireBrowserSurfaceOcclusion({ x: 500, y: 0, width: 100, height: 100 }); + await flushPromises(); + expect(setBounds.mock.calls).toEqual([["sync-rect-occlusion", rect, true]]); + + popup.update({ x: 100, y: 100, width: 50, height: 50 }); + await flushPromises(); + expect(setBounds.mock.calls).toEqual([ + ["sync-rect-occlusion", rect, true], + ["sync-rect-occlusion", rect, false], + ]); + + popup.release(); + await flushPromises(); + expect(setBounds.mock.calls).toEqual([ + ["sync-rect-occlusion", rect, true], + ["sync-rect-occlusion", rect, false], + ["sync-rect-occlusion", rect, true], + ]); + stop(); + }); + it("restores the latest desired bounds after an occlusion", async () => { const setBounds = vi.fn().mockResolvedValue(undefined); const stop = startSync(setBounds); diff --git a/apps/web/src/browser/browserSurfaceSync.ts b/apps/web/src/browser/browserSurfaceSync.ts index 8ccc805a4b..14637b1292 100644 --- a/apps/web/src/browser/browserSurfaceSync.ts +++ b/apps/web/src/browser/browserSurfaceSync.ts @@ -1,6 +1,6 @@ import type { DesktopPreviewBridge } from "@bibcode/contracts"; -import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { isBrowserSurfaceOccluded, useBrowserSurfaceStore } from "./browserSurfaceStore"; interface SyncedPresentation { readonly x: number; @@ -83,7 +83,6 @@ export function startBrowserSurfaceSync( }; const push = (state: ReturnType) => { - const occluded = state.occlusionOwners.size > 0; for (const [tabId, presentation] of Object.entries(state.byTabId)) { if (!presentation.rect) continue; const next: SyncedPresentation = { @@ -91,7 +90,9 @@ export function startBrowserSurfaceSync( y: presentation.rect.y, width: presentation.rect.width, height: presentation.rect.height, - visible: presentation.visible && !occluded, + visible: + presentation.visible && + !isBrowserSurfaceOccluded(presentation.rect, state.occlusionOwners), }; schedule(tabId, next); } diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index c6223b5adc..fdbb70982d 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -1,5 +1,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { getSharedHighlighter } from "@pierre/diffs"; +import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@bibcode/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@bibcode/contracts/settings"; import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -7,6 +8,7 @@ import { Window } from "happy-dom"; import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; import { vi } from "vite-plus/test"; import { __setClientSettingsForTests } from "../hooks/useSettings"; +import { joinWorkspacePath } from "./files/FileTreeContextMenu.logic"; import { writeThemePreference } from "../hooks/useTheme"; vi.mock("./ui/menu", () => ({ @@ -22,6 +24,45 @@ vi.mock("./ui/menu", () => ({ ), })); +// The workspace image loader's boundary, as RichMarkdownImage.test.tsx mocks it. Only a +// workspace-file asset request is intercepted; everything else ChatMarkdown reads stays real. +const assetState = vi.hoisted(() => ({ + result: { _tag: "Initial" } as { _tag: "Initial" }, + requests: [] as unknown[], + marker: Symbol("workspace-asset-request"), +})); +vi.mock("@effect/atom-react", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + useAtomValue: ((atom: unknown, ...rest: unknown[]) => + typeof atom === "object" && atom !== null && assetState.marker in atom + ? assetState.result + : (original.useAtomValue as (...args: unknown[]) => unknown)( + atom, + ...rest, + )) as typeof original.useAtomValue, + }; +}); +vi.mock("~/state/assets", async (importOriginal) => { + const original = await importOriginal(); + const createUrl = (request: { input: { resource: { _tag: string } } }) => { + if (request.input.resource._tag !== "workspace-file") { + return original.assetEnvironment.createUrl(request as never); + } + assetState.requests.push(request); + return { [assetState.marker]: true }; + }; + return { + ...original, + assetEnvironment: { ...original.assetEnvironment, createUrl }, + }; +}); +vi.mock("~/state/environments", async (importOriginal) => ({ + ...(await importOriginal()), + useEnvironmentHttpBaseUrl: () => "https://server.example.test/", +})); + let ChatMarkdownComponent: typeof import("./ChatMarkdown").default | null = null; let domWindow: Window | null = null; @@ -86,6 +127,8 @@ interface RenderOptions { detailsOpen?: ReadonlyMap; onDetailsOpenChange?: (ordinal: number, open: boolean) => void; findScope?: string; + threadRef?: ScopedThreadRef; + markdownPath?: string; } async function renderMarkdown(text: string, options: RenderOptions = {}) { @@ -1186,4 +1229,61 @@ describe("ChatMarkdown", () => { expect(stream.container.innerHTML).not.toContain("pierre-light"); }); }); + + describe("images", () => { + const threadRef: ScopedThreadRef = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + }; + + beforeEach(() => { + assetState.requests.length = 0; + }); + + it("relative image reads from the workspace when a markdownPath is given", async () => { + const container = await mountMarkdown("![A pixel](img/a.png)", { + cwd: "/workspace", + threadRef, + markdownPath: "docs/readme.md", + }); + + expect(assetState.requests).toEqual([ + { + environmentId: threadRef.environmentId, + input: { + resource: { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: joinWorkspacePath("/workspace", "docs/img/a.png"), + }, + }, + }, + ]); + expect(container.querySelector("[data-rich-markdown-image-text=loading]")).not.toBeNull(); + expect(container.querySelector("img")).toBeNull(); + }); + + it("relative image outside the workspace is refused with the loader's message", async () => { + const container = await mountMarkdown("![Outside](../../outside.png)", { + cwd: "/workspace", + threadRef, + markdownPath: "docs/readme.md", + }); + + expect(assetState.requests).toEqual([]); + expect( + container.querySelector("[data-rich-markdown-image-text=failed]")?.getAttribute("title"), + ).toBe("Only images inside this project are shown."); + }); + + it("image without a markdownPath stays a plain image", async () => { + const markup = await renderMarkdown("![A pixel](img/a.png)", { + cwd: "/workspace", + threadRef, + }); + + expect(assetState.requests).toEqual([]); + expect(markup).toContain('A pixel'); + }); + }); }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 3ff0ce2e1a..dcd3966357 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -95,6 +95,7 @@ import { showInternalBrowserFailedNotice, } from "../browser/linkNotices"; import { openLink } from "../browser/openLink"; +import { RichMarkdownImage } from "./files/richMarkdown/RichMarkdownImage"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -136,6 +137,11 @@ interface ChatMarkdownProps { onDetailsOpenChange?: ((ordinal: number, open: boolean) => void) | undefined; /** Marks the rendered text as one Find in thread scope, named by this value. */ findScope?: string | undefined; + /** + * The Markdown file shown, relative to `cwd`. With a thread, its relative images are read from + * the workspace the way the rich editor reads them. Chat messages pass none. + */ + markdownPath?: string | undefined; } const EMPTY_MARKDOWN_SKILLS: ReadonlyArray> = []; @@ -1061,6 +1067,8 @@ interface ChatMarkdownRenderState { readonly fileLinks: MarkdownFileLinks; readonly environmentId: ReturnType; readonly threadRef: ScopedThreadRef | undefined; + readonly cwd: string | undefined; + readonly markdownPath: string | undefined; readonly skills: MarkdownSkills; readonly onTaskListChange: ChatMarkdownProps["onTaskListChange"]; readonly openInPreferredEditor: MarkdownFileLinkProps["onOpen"]; @@ -1350,7 +1358,27 @@ function MarkdownPre({ node, children, ...props }: MarkdownComponentProps<"pre"> ); } +function MarkdownImage({ node: _node, src, alt, ...props }: MarkdownComponentProps<"img">) { + const { threadRef, cwd, markdownPath } = useChatMarkdownRenderState(); + if (markdownPath === undefined || !threadRef || !cwd || typeof src !== "string") { + return {alt}; + } + return ( + + ); +} + const CHAT_MARKDOWN_COMPONENTS: Components = { + img: MarkdownImage, p: MarkdownParagraph, li: MarkdownListItem, input: MarkdownInput, @@ -1372,6 +1400,7 @@ function ChatMarkdown({ detailsOpen, onDetailsOpenChange, findScope, + markdownPath, }: ChatMarkdownProps) { // Links and files open in the right panel on screen: a chat panel's is its host's. const threadRef = use(RightPanelThreadContext) ?? messageThreadRef; @@ -1447,6 +1476,8 @@ function ChatMarkdown({ fileLinks, environmentId, threadRef, + cwd, + markdownPath, skills, onTaskListChange, openInPreferredEditor, @@ -1454,8 +1485,10 @@ function ChatMarkdown({ openMarkdownFileInPreview, }), [ + cwd, diffThemeName, environmentId, + markdownPath, fileLinks, isStreaming, onTaskListChange, diff --git a/apps/web/src/components/ChatView.test.tsx b/apps/web/src/components/ChatView.test.tsx index f9d0fce6bf..356bfff9fc 100644 --- a/apps/web/src/components/ChatView.test.tsx +++ b/apps/web/src/components/ChatView.test.tsx @@ -5519,11 +5519,19 @@ describe("ChatView handlers (captured from mocked children)", () => { // not a scroll, so the turn's end comes back into view. A press on the list itself, such as // its scrollbar, is still the reader taking over. it.each([ - { pressed: "a Thinking row's toggle", onControl: true, revealsEnd: true }, - { pressed: "the list itself", onControl: false, revealsEnd: false }, + { pressed: "a Thinking row's toggle", onControl: true, newestInGroup: true, revealsEnd: true }, + { pressed: "the list itself", onControl: false, newestInGroup: true, revealsEnd: false }, + // R12 scroll path (#351): thought A of a work row grouping [A, tool B, thought C] is not + // the newest step, although no later list row exists, so it keeps its place. + { + pressed: "a Thinking row's toggle with a later step in its row", + onControl: true, + newestInGroup: false, + revealsEnd: false, + }, ])( "a pointer press on $pressed before a toggle at an anchored turn's end", - async ({ onControl, revealsEnd }) => { + async ({ onControl, newestInGroup, revealsEnd }) => { vi.unstubAllGlobals(); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); seedConnectedServerThread(); @@ -5589,7 +5597,7 @@ describe("ChatView handlers (captured from mocked children)", () => { new PointerEvent("pointerdown", { bubbles: true }), ); // The turn ends in view; the toggle then opens a trace that pushes its end below. - (timeline["onReasoningRowToggle"] as () => void)(); + (timeline["onReasoningRowToggle"] as (newestInGroup: boolean) => void)(newestInGroup); geometry.lastRowHeight = 3000; await nextFrames(3); @@ -5605,6 +5613,121 @@ describe("ChatView handlers (captured from mocked children)", () => { }, ); + // #351, the Windows trace: the spec scrolled a Thinking row to the center (scrollIntoView, no + // wheel or touch), toggled it, and a streamed entry about 150 ms later ran the live follow, + // which revealed the anchored turn's end from scroll 0 (1014 px) because the move away from + // the end had been ignored as not the reader's own. + it("R12 scroll path: a streamed entry after a programmatic scroll away from an anchored turn's end keeps the reader's place", async () => { + vi.unstubAllGlobals(); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const thread = makeThread(); + seedConnectedServerThread(thread); + const scrollNode = document.createElement("div"); + document.body.append(scrollNode); + // The trace's metrics: a 643 px view over a turn whose end sat 780 px below scroll 0. + const geometry = { scroll: 796, turnHeight: 1339 }; + Object.defineProperty(scrollNode, "scrollTop", { + configurable: true, + get: () => geometry.scroll, + set: (value: number) => { + geometry.scroll = value; + }, + }); + const list = { + getScrollableNode: () => scrollNode, + getState: () => ({ + scroll: geometry.scroll, + scrollLength: 643, + contentLength: 100 + geometry.turnHeight, + data: [{}, {}], + positionAtIndex: (index: number) => index * 100, + sizeAtIndex: (index: number) => (index === 1 ? geometry.turnHeight : 100), + }), + scrollToOffset: vi.fn(({ offset }: { offset: number }) => { + geometry.scroll = offset; + }), + scrollToIndex: vi.fn(), + scrollToEnd: vi.fn(), + }; + h.timelineList = list; + const nextFrames = (count: number) => + act( + () => + new Promise((resolve) => { + const step = (left: number) => + left === 0 ? resolve() : requestAnimationFrame(() => step(left - 1)); + step(count); + }), + ); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const view = () => ( + + ); + try { + await act(async () => root.render(view())); + await nextFrames(2); + const composer = capturedProps>("chatComposer"); + (composer["composerRef"] as RefObject).current = composerHandle(); + (composer["promptRef"] as RefObject).current = "think it through"; + await act(async () => (composer["onSend"] as () => Promise)()); + const sent = commandCallsFor("thread.startTurn")[0]!.input as { + input: { message: { messageId: MessageId } }; + }; + let timeline = capturedProps>("messagesTimeline"); + await act(async () => + (timeline["onAnchorReady"] as (id: MessageId, index: number) => void)( + sent.input.message.messageId, + 0, + ), + ); + // The sent row's glide runs out and the row settles, as it had long before the toggle. + await nextFrames(70); + + // At the turn's end, LegendList reports it; scrollIntoView({ block: "center" }) then + // moves the list to 0 and it reports that too. + timeline = capturedProps>("messagesTimeline"); + geometry.scroll = 796; + await act(async () => (timeline["onIsAtEndChange"] as (isAtEnd: boolean) => void)(true)); + geometry.scroll = 0; + await act(async () => (timeline["onIsAtEndChange"] as (isAtEnd: boolean) => void)(false)); + list.scrollToOffset.mockClear(); + list.scrollToEnd.mockClear(); + list.scrollToIndex.mockClear(); + + // The trace's thought was the only one in its work row. + (timeline["onReasoningRowToggle"] as (newestInGroup: boolean) => void)(true); + geometry.turnHeight += 234; + // The thought is still streaming: its next delivery is a new timeline entry. + seedServerThread({ + ...thread, + messages: [ + { + id: MessageId.make("message-streaming"), + role: "assistant", + text: "Streaming update", + turnId: null, + createdAt: now, + updatedAt: "2026-07-22T20:11:00.000Z", + streaming: true, + }, + ], + updatedAt: "2026-07-22T20:11:00.000Z", + }); + await act(async () => root.render(view())); + await nextFrames(3); + + expect(list.scrollToOffset).not.toHaveBeenCalled(); + expect(list.scrollToEnd).not.toHaveBeenCalled(); + expect(list.scrollToIndex).not.toHaveBeenCalled(); + } finally { + await act(async () => root.unmount()); + container.remove(); + scrollNode.remove(); + } + }); + it("onInterrupt targets the running turn of the active session", async () => { const runningTurnId = TurnId.make("turn-running"); seedConnectedServerThread( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index dbb6deb8e1..0ccb828a65 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -139,8 +139,10 @@ import { getAnchoredTurnMetrics, getSentRowLandingDelta, bindTimelineTakeoverListeners, + type TimelineListPosition, type TimelineTakeoverInput, getTimelinePageScrollOffset, + listMovedAwayFromEnd, rowToggleNeedsEndReveal, timelineScrollAnimates, type TimelineScrollMode, @@ -3904,7 +3906,10 @@ function ChatViewContent(props: ChatViewProps) { // remounts per thread (it is keyed by thread), and a thread with no rows shows an empty state // instead, so a new thread's list mounts, and binds, with its first turn. const timelineTakeoverCleanupRef = useRef<(() => void) | null>(null); + // Where the list was at its last at-end report; another thread's list starts afresh. + const lastTimelineListPositionRef = useRef(null); const onTimelineListMount = useCallback((list: LegendListRef | null) => { + lastTimelineListPositionRef.current = null; timelineTakeoverCleanupRef.current?.(); timelineTakeoverCleanupRef.current = null; const scrollNode = list?.getScrollableNode(); @@ -4143,15 +4148,34 @@ function ChatViewContent(props: ChatViewProps) { }, []); const onIsAtEndChange = useCallback((isAtEnd: boolean) => { + const state = legendListRef.current?.getState(); + const previous = lastTimelineListPositionRef.current; + const position = + state && typeof state.contentLength === "number" + ? { scroll: state.scroll, contentLength: state.contentLength } + : null; + lastTimelineListPositionRef.current = position; + // Following ignores the end leaving view, which growth below it does too, but not a scroll + // up through the content that no gesture made, such as `scrollIntoView` (#351): following + // on from there would throw the reader back to the end. A sent row still being positioned + // is the timeline's own scroll. + const leftEnd = + !isAtEnd && + previous !== null && + position !== null && + listMovedAwayFromEnd(previous, position) && + pendingTimelineAnchorRef.current === null && + settledTimelineAnchorRef.current === positionedTimelineAnchorRef.current; if ( !isAtEnd && + !leftEnd && liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current ) { showScrollDebouncer.current.cancel(); setScrollPillThreadId(null); return; } - if (isAtEndRef.current === isAtEnd) return; + if (isAtEndRef.current === isAtEnd && !leftEnd) return; isAtEndRef.current = isAtEnd; if (isAtEnd) { timelineScrollModeRef.current = "following-end"; @@ -4169,35 +4193,42 @@ function ChatViewContent(props: ChatViewProps) { // A Thinking row toggled at the end of an anchored turn changes layout but not // `timelineEntries`, so the follow effect below never sees it (#173). One pass reveals the - // end again once the row re-measured; the other modes keep their own place. - const onReasoningRowToggle = useCallback(() => { - // The toggle has not rendered yet, so this is where the turn's end sat before it. - const before = legendListRef.current - ? getActiveTimelineTurnMetrics(legendListRef.current) - : null; - if ( - !rowToggleNeedsEndReveal({ - mode: timelineScrollModeRef.current, - scrollDeltaToRevealEnd: before?.scrollDeltaToRevealEnd ?? null, - }) || - liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current - ) { - return; - } - requestAnimationFrame(() => { + // end again once the row re-measured; the other modes keep their own place. A thought with + // later work steps after it is not at the turn's end, whatever is in view (#351). + const onReasoningRowToggle = useCallback( + (newestStep: boolean) => { + if (!newestStep) { + return; + } + // The toggle has not rendered yet, so this is where the turn's end sat before it. + const before = legendListRef.current + ? getActiveTimelineTurnMetrics(legendListRef.current) + : null; + if ( + !rowToggleNeedsEndReveal({ + mode: timelineScrollModeRef.current, + scrollDeltaToRevealEnd: before?.scrollDeltaToRevealEnd ?? null, + }) || + liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current + ) { + return; + } requestAnimationFrame(() => { - const list = legendListRef.current; - const metrics = list ? getActiveTimelineTurnMetrics(list) : null; - if (!list || !metrics || metrics.scrollDeltaToRevealEnd <= 1) { - return; - } - void list.scrollToOffset({ - offset: list.getState().scroll + metrics.scrollDeltaToRevealEnd, - animated: false, + requestAnimationFrame(() => { + const list = legendListRef.current; + const metrics = list ? getActiveTimelineTurnMetrics(list) : null; + if (!list || !metrics || metrics.scrollDeltaToRevealEnd <= 1) { + return; + } + void list.scrollToOffset({ + offset: list.getState().scroll + metrics.scrollDeltaToRevealEnd, + animated: false, + }); }); }); - }); - }, [getActiveTimelineTurnMetrics]); + }, + [getActiveTimelineTurnMetrics], + ); // One pass of live following: reveals an anchored turn's end, or keeps the end in view. It // waits while a sent row is still being positioned, which then runs it once it settles. diff --git a/apps/web/src/components/RemoteUpdateRequestNotifier.install.test.tsx b/apps/web/src/components/RemoteUpdateRequestNotifier.install.test.tsx new file mode 100644 index 0000000000..e345397e6c --- /dev/null +++ b/apps/web/src/components/RemoteUpdateRequestNotifier.install.test.tsx @@ -0,0 +1,187 @@ +// @vitest-environment happy-dom +import type { DesktopBridge } from "@bibcode/contracts"; +import { act, Children, isValidElement, type ReactElement, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("../env", async (importOriginal) => ({ + ...(await importOriginal()), + isDesktopHost: true, +})); +// The real toast viewport reads the route; no router is mounted here. +vi.mock("@tanstack/react-router", async (importOriginal) => ({ + ...(await importOriginal()), + useParams: ({ select }: { select?: (params: object) => unknown }) => select?.({}), +})); + +import type { AppRouter } from "../router"; + +const MARKER = "bibcode:update-install-allowed"; +const REASON = "Manage devices is available when the update finishes."; +const requestedBy = { label: "Test User", detail: null }; +const running = { requestedBy, phase: "installing" }; +const idle = { requestedBy: null, phase: "idle" }; +const completedLook = { requestedBy: null, phase: "installing" }; + +type Listener = (state: unknown) => void; +type Query = { resolve: (state: unknown) => void; reject: (error: unknown) => void }; + +/** One desktop: the close host subscribes first, the real update-state atom second. */ +function fakeDesktop() { + const desktop = { + listeners: [] as Listener[], + queries: [] as Query[], + ended: null as (() => void) | null, + bridge: { + onCloseRequest: () => () => {}, + replyCloseRequest: async () => {}, + onUpdateInstallEnded: (listener: () => void) => { + desktop.ended = listener; + return () => {}; + }, + onUpdateState: (listener: Listener) => { + desktop.listeners.push(listener); + return () => {}; + }, + getUpdateState: () => + new Promise((resolve, reject) => { + desktop.queries.push({ resolve, reject }); + }), + } as unknown as DesktopBridge, + toHost: (state: unknown) => act(async () => desktop.listeners[0]!(state)), + toAtom: (state: unknown) => act(async () => desktop.listeners[1]!(state)), + }; + return desktop; +} + +const manageDevices = () => + [...document.querySelectorAll('[data-slot="toast-action"]')].find( + (candidate) => candidate.textContent?.trim() === "Manage devices", + ); +const toastText = () => document.querySelector('[data-slot="toast-portal"]')?.textContent ?? ""; +const dialogText = () => document.querySelector('[data-slot="alert-dialog-popup"]')?.textContent; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + Object.defineProperty(Element.prototype, "getAnimations", { + configurable: true, + value: () => [], + }); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + delete window.desktopBridge; + window.sessionStorage.clear(); + document.body.replaceChildren(); + Reflect.deleteProperty(Element.prototype, "getAnimations"); + vi.restoreAllMocks(); +}); + +/** + * A window reloaded during an allowed install, with the notice up, then the end of the install. + * Fresh modules per run: the update-state atom and the install block live in module state. + */ +async function installWindow() { + vi.resetModules(); + const { AppRoot } = await import("../AppRoot"); + const { UnsavedChangesCloseHost } = await import("./files/UnsavedChangesCloseDialog"); + const { RemoteUpdateRequestNotifier } = await import("./RemoteUpdateRequestNotifier"); + const { ToastProvider } = await import("./ui/toast"); + const desktop = fakeDesktop(); + window.desktopBridge = desktop.bridge; + window.sessionStorage.setItem(MARKER, "1"); + const navigate = vi.fn(async () => undefined); + + // The notifier exactly as `AppRoot` mounts it, with its real Manage devices callback. + let tree: ReactElement<{ readonly children: ReactNode }> | null = null; + function Capture() { + tree = AppRoot({ router: { navigate } as unknown as AppRouter }) as typeof tree; + return null; + } + await act(async () => root.render()); + const notifier = Children.toArray(tree!.props.children).find( + (child) => isValidElement(child) && child.type === RemoteUpdateRequestNotifier, + ); + if (!isValidElement(notifier)) throw new Error("AppRoot does not mount the notifier"); + + const mount = (withNotifier: boolean) => + act(async () => + root.render( + + + {withNotifier ? notifier : null} + , + ), + ); + // The toast viewport and the close host subscribe first, as in the app. + await mount(false); + await mount(true); + // queries[0] is the close host's, queries[1] the atom's first read. + await vi.waitFor(() => expect(desktop.queries).toHaveLength(2), { timeout: 10_000 }); + await act(async () => desktop.queries[1]!.resolve(running)); + await vi.waitFor(() => expect(manageDevices()?.disabled).toBe(true), { timeout: 10_000 }); + expect(dialogText()).toContain("Installing an update"); + expect(toastText()).toContain(REASON); + return { desktop, navigate }; +} + +type Desktop = ReturnType; +const endings: Record Promise> = { + "the ended event": async (desktop) => { + await act(async () => desktop.ended!()); + }, + "a pushed state": async (desktop) => { + await desktop.toHost(idle); + }, + "a confirmed query answer": async (desktop) => { + await act(async () => desktop.queries[0]!.resolve(idle)); + }, + "Check again": async (desktop) => { + await act(async () => desktop.queries[0]!.resolve(running)); + const check = [...document.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === "Check again", + )!; + await act(async () => check.click()); + await act(async () => desktop.queries.at(-1)!.resolve(idle)); + }, +}; + +describe("Manage devices on the remote-update notice", () => { + for (const [ending, end] of Object.entries(endings)) { + for (const order of ["requester clears first", "block ends first"] as const) { + it(`ends enabled when ${order}, through ${ending}`, async () => { + const { desktop, navigate } = await installWindow(); + // Settings would open beneath the dialog, where nobody can use it. + await act(async () => { + manageDevices()!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(navigate).not.toHaveBeenCalled(); + + // A completed install clears the requester while the phase stays "installing". + if (order === "requester clears first") await desktop.toAtom(completedLook); + await end(desktop); + if (order === "block ends first") await desktop.toAtom(idle); + + expect(dialogText()).toBeUndefined(); + await vi.waitFor(() => expect(manageDevices()?.disabled).toBe(false), { + timeout: 10_000, + }); + expect(toastText()).not.toContain(REASON); + await act(async () => { + manageDevices()!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(navigate).toHaveBeenCalledWith({ + to: "/settings/remote-servers", + search: { tab: "share" }, + }); + }, 30_000); + } + } +}); diff --git a/apps/web/src/components/RemoteUpdateRequestNotifier.test.tsx b/apps/web/src/components/RemoteUpdateRequestNotifier.test.tsx index 472bfc2228..51003f3900 100644 --- a/apps/web/src/components/RemoteUpdateRequestNotifier.test.tsx +++ b/apps/web/src/components/RemoteUpdateRequestNotifier.test.tsx @@ -9,7 +9,10 @@ const state = vi.hoisted(() => ({ })); vi.mock("../state/desktopUpdate", () => ({ useDesktopUpdateState: () => state.update })); vi.mock("./ui/toast", () => ({ - toastManager: { add: (toast: Record) => state.toasts.push(toast) }, + toastManager: { + add: (toast: Record) => String(state.toasts.push(toast)), + update: vi.fn(), + }, stackedThreadToast: (input: unknown) => input, })); import { diff --git a/apps/web/src/components/RemoteUpdateRequestNotifier.tsx b/apps/web/src/components/RemoteUpdateRequestNotifier.tsx index c031c5c56f..516968139b 100644 --- a/apps/web/src/components/RemoteUpdateRequestNotifier.tsx +++ b/apps/web/src/components/RemoteUpdateRequestNotifier.tsx @@ -1,5 +1,6 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useSyncExternalStore } from "react"; import { useDesktopUpdateState } from "../state/desktopUpdate"; +import { isUpdateInstalling, subscribeLeaveQuestion } from "./files/unsavedFileEdits"; import { stackedThreadToast, toastManager } from "./ui/toast"; export function remoteUpdateRequestToast( @@ -16,6 +17,9 @@ export function remoteUpdateRequestToast( }; } +/** Shown while the installing dialog holds the window: Settings would open beneath it. */ +export const MANAGE_DEVICES_AFTER_INSTALL = "Manage devices is available when the update finishes."; + /** Host notice stays informational; remote installs are already authorized by the requesting client. */ export function RemoteUpdateRequestNotifier({ onManageDevices, @@ -24,6 +28,20 @@ export function RemoteUpdateRequestNotifier({ }) { const state = useDesktopUpdateState(); const requestedBy = state?.requestedBy ?? null; + const installing = useSyncExternalStore( + subscribeLeaveQuestion, + isUpdateInstalling, + isUpdateInstalling, + ); + const manageDevices = useRef(onManageDevices); + useEffect(() => { + manageDevices.current = onManageDevices; + }); + const notice = useRef<{ id: string; text: ReturnType } | null>( + null, + ); + // Announcing tracks the request; `notice` outlives it, so a block released after the requester + // cleared still re-enables the action and drops its reason. const announced = useRef(false); useEffect(() => { if (requestedBy === null) { @@ -32,17 +50,39 @@ export function RemoteUpdateRequestNotifier({ } if (announced.current) return; announced.current = true; - toastManager.add( - stackedThreadToast({ - type: "info", - ...remoteUpdateRequestToast( - requestedBy, - state?.downloadedVersion ?? state?.availableVersion ?? null, - ), - actionVariant: "outline", - actionProps: { children: "Manage devices", onClick: onManageDevices }, - }), + const text = remoteUpdateRequestToast( + requestedBy, + state?.downloadedVersion ?? state?.availableVersion ?? null, ); - }, [onManageDevices, requestedBy, state?.downloadedVersion, state?.availableVersion]); + const id = toastManager.add(noticeToast(text, isUpdateInstalling(), manageDevices)); + notice.current = { id, text }; + }, [requestedBy, state?.downloadedVersion, state?.availableVersion]); + useEffect(() => { + const current = notice.current; + if (current === null) return; + toastManager.update(current.id, noticeToast(current.text, installing, manageDevices)); + }, [installing]); return null; } + +function noticeToast( + text: ReturnType, + installing: boolean, + manageDevices: { readonly current: () => void }, +) { + return stackedThreadToast({ + type: "info", + title: text.title, + description: installing + ? `${text.description} ${MANAGE_DEVICES_AFTER_INSTALL}` + : text.description, + actionVariant: "outline", + actionProps: { + children: "Manage devices", + disabled: installing, + onClick: () => { + if (!isUpdateInstalling()) manageDevices.current(); + }, + }, + }); +} diff --git a/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx b/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx index 412df1485a..e62b8f5190 100644 --- a/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx @@ -45,6 +45,8 @@ interface FakeTerminalInstance { readonly resize: ReturnType; readonly scrollToBottom: ReturnType; readonly clearTextureAtlas: ReturnType; + readonly _core: { readonly _charSizeService: { readonly measure: ReturnType } }; + readonly unicode: { activeVersion: string }; readonly dispose: ReturnType; readonly loadAddon: ReturnType; readonly clearSelection: ReturnType; @@ -310,6 +312,14 @@ vi.mock("@xterm/addon-webgl", () => { return { WebglAddon }; }); +vi.mock("@xterm/addon-unicode11", () => ({ + Unicode11Addon: class Unicode11Addon { + readonly kind = "unicode11" as const; + activate() {} + dispose() {} + }, +})); + // Only the loader is faked; `webglContextFrom`/`webglCanvasFrom` stay real so // the addon plumbing under test is the code that actually ships. vi.mock("./terminalWebgl", async (importOriginal) => ({ @@ -371,6 +381,9 @@ vi.mock("@xterm/xterm", () => ({ }; readonly scrollToBottom = vi.fn(); readonly clearTextureAtlas = vi.fn(); + // xterm 6.0.0's private cell measurement, which a late font load re-runs (#348). + readonly _core = { _charSizeService: { measure: vi.fn() } }; + readonly unicode = { activeVersion: "6" }; readonly loadedAddons: Array<{ dispose?: () => void }> = []; readonly dispose = vi.fn(() => { this.disposed = true; @@ -512,6 +525,7 @@ const testState = vi.hoisted(() => ({ terminalOrderedInput?: boolean; terminalSizeOwnership?: boolean; terminalUserInputClaim?: boolean; + terminalSizeHandOver?: boolean; terminalImagePaste?: boolean; }; }; @@ -704,6 +718,12 @@ import ThreadTerminalPanel, { TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS, TerminalViewport, } from "./ThreadTerminalPanel"; +import { proposeTerminalDimensions } from "./terminalSizing"; +import { + clearTerminalSizeDiagnostics, + readTerminalSizeDiagnostics, + setTerminalSizeDiagnosticsEnabled, +} from "./terminalSizeDiagnostics"; import { decodeTerminalLaunchCommand } from "../lib/terminalLaunchCommand"; import { useCenterPanelStore } from "../centerPanelStore"; import { openLink } from "~/browser/openLink"; @@ -1026,6 +1046,18 @@ function trackedTranscriptRuntime(initialSnapshot: string): { }; } +/** A `document.fonts` whose `loadingdone` the test fires: happy-dom loads no fonts. */ +function stubDocumentFonts(): EventTarget { + const fonts = Object.assign(new EventTarget(), { status: "loaded" }); + Object.defineProperty(document, "fonts", { configurable: true, value: fonts }); + return fonts; +} + +/** The order of the first call to `mocks` after they were last cleared, or Infinity. */ +function firstCallOrder(...mocks: ReturnType[]): number { + return Math.min(...mocks.map((mock) => mock.mock.invocationCallOrder[0] ?? Infinity)); +} + async function flushAnimationFrames(): Promise { await act(async () => { while (animationFrames.size > 0) { @@ -1514,6 +1546,7 @@ afterEach(async () => { xtermState.fitAddons = []; document.body.replaceChildren(); Reflect.deleteProperty(document, "execCommand"); + Reflect.deleteProperty(document, "fonts"); (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false; vi.unstubAllGlobals(); vi.restoreAllMocks(); @@ -1801,12 +1834,26 @@ describe("TerminalViewport mounted lifecycle", () => { terminal.clearTextureAtlas.mockClear(); fitAddon.fit.mockClear(); testState.fontLoad.mockClear(); + terminal._core._charSizeService.measure.mockClear(); fitAddon.fit.mockImplementation(() => { terminal.cols += 1; }); - await view.setTerminalFontPreference({ mode: "custom", family: "Maple Mono" }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + await view.setTerminalFontPreference({ mode: "custom", family: "Maple Mono" }); + // The loaded font's cell waits for the mount to settle before the claim (#348). + elapseMountSettle(); + await act(async () => vi.advanceTimersByTime(TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS)); + } finally { + vi.useRealTimers(); + } + // xterm measured at the assignment, before the font loaded; the load measures again. + expect(terminal._core._charSizeService.measure).toHaveBeenCalledOnce(); + expect(terminal._core._charSizeService.measure.mock.invocationCallOrder[0]).toBeLessThan( + fitAddon.fit.mock.invocationCallOrder[0]!, + ); expect(xtermState.terminals).toHaveLength(1); expect(terminal.dispose).not.toHaveBeenCalled(); expect(view.detachRendererSpy).not.toHaveBeenCalled(); @@ -2161,10 +2208,68 @@ describe("TerminalViewport mounted lifecycle", () => { expect(view.resizeSpy).toHaveBeenCalledTimes(2); }); + /** Drives `applySize` through a reset, an `attached` and a `resized` of one un-owned size. */ + const handOverSignals = async (capabilities: Record) => { + testState.serverConfig!.environment.capabilities = { + terminalSizeOwnership: true, + terminalUserInputClaim: true, + ...capabilities, + }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport(); + const size = { cols: 151, rows: 50, sizeClaim: null, replyClaim: "other-window" }; + const snapshot: TerminalAttachStreamEvent = { + type: "snapshot", + snapshot: { ...terminalSnapshot("prompt"), size }, + }; + const fitShown = () => view.mounted.container.textContent?.includes("Fit to this window"); + const steps: Array<{ claimed: boolean; fitShown: boolean | undefined }> = []; + const record = async (signal: () => void) => { + view.resizeSpy.mockClear(); + await act(async () => signal()); + await view.flushFrame(); + steps.push({ claimed: view.resizeSpy.mock.calls.length > 0, fitShown: fitShown() }); + }; + elapseMountSettle(); + await record(() => view.runtime.ingest(snapshot)); + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + await record(() => view.runtime.ingest(snapshot, { sizeClaim: claim })); + await record(() => + view.runtime.ingest({ type: "resized", threadId: THREAD_ID, terminalId: "term-1", size }), + ); + return steps; + }; + + it.each([ + ["absent", {}], + ["false", { terminalSizeHandOver: false }], + ] as const)( + "keeps the claim rule without the hand-over capability (%s): reset, attached and resized", + async (_case, extra) => { + const steps = await handOverSignals(extra); + expect(steps).toEqual([ + { claimed: true, fitShown: false }, + { claimed: true, fitShown: false }, + { claimed: true, fitShown: false }, + ]); + }, + ); + + it("applies the hand-over rule with the hand-over capability: reset, attached and resized", async () => { + const steps = await handOverSignals({ terminalSizeHandOver: true }); + expect(steps).toEqual([ + { claimed: false, fitShown: true }, + { claimed: false, fitShown: true }, + { claimed: false, fitShown: true }, + ]); + }); + it("waits for use of a size a handing-over server released, but claims a restart's (#348)", async () => { testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true, terminalUserInputClaim: true, + terminalSizeHandOver: true, }; vi.spyOn(document, "hasFocus").mockReturnValue(false); const view = await mountViewport(); @@ -2303,6 +2408,33 @@ describe("TerminalViewport mounted lifecycle", () => { expect(written()).toBe("\x1b[1;1R\x1b[?1;2c"); }); + it.each([false, true])( + "keeps the grid within its mount before the first snapshot after a reload (ownership %s)", + { timeout: 30_000 }, + async (ownership) => { + // R43 (#348), decided by this test: the fit fake resizes the grid as the real addon does. + xtermState.fitAppliesProposal = true; + xtermState.proposedDimensions = { cols: 80, rows: 20 }; + if (ownership) + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + const view = await mountViewport({ publishRuntime: false }); + await view.flushFrame(); + await view.flushMountFit(); + const terminal = view.fakeTerminal!; + const mount = view.mounted.container.querySelector( + "[data-terminal-xterm-mount]", + )!; + const fitted = proposeTerminalDimensions( + xtermState.fitAddons.at(-1)! as unknown as Parameters[0], + mount, + ); + expect( + terminal.rows, + JSON.stringify({ rows: terminal.rows, mountHeight: mount.clientHeight, fitted }), + ).toBeLessThanOrEqual(20); + }, + ); + it("sends keystrokes through the input scheduler before the first snapshot", async () => { const view = await mountViewport({ publishRuntime: false }); await act(async () => view.fakeTerminal!.dataHandler!("echo ready\r")); @@ -3544,8 +3676,17 @@ describe("TerminalViewport mounted lifecycle", () => { mount.style.width = ""; xtermState.proposedDimensions = { cols: 80, rows: 24 }; - await view.resizeContainer(); - await view.flushFrame(); + terminal.refresh.mockClear(); + // The same-size return repaints, though nothing is sized (#348). + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + await view.resizeContainer(); + await view.flushFrame(); + await act(async () => vi.advanceTimersByTime(50)); + } finally { + vi.useRealTimers(); + } + expect(terminal.refresh).toHaveBeenCalledWith(0, terminal.rows - 1); expect(view.resizeSpy).not.toHaveBeenCalled(); expect(terminal.cols).toBe(80); @@ -3643,6 +3784,9 @@ describe("TerminalViewport mounted lifecycle", () => { for (const listener of watched()[0]!.listeners) listener(); }); await view.flushFrame(); + // A new ratio's rows wait for the mount to settle, as a box change does (#348). + elapseMountSettle(); + await act(async () => vi.advanceTimersByTime(TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS)); if (mode === "legacy") { expect(fit).toHaveBeenCalledOnce(); expect(terminal.rows).toBe(23); @@ -3683,7 +3827,8 @@ describe("TerminalViewport mounted lifecycle", () => { expect(terminal.rows).toBe(24); } await act(async () => vi.advanceTimersByTime(50)); - expect(terminal.refresh).toHaveBeenCalledWith(0, terminal.rows - 1); + // The repaint runs at 50 ms, on the grid before the settled claim; xterm redraws a resize. + expect(terminal.refresh).toHaveBeenCalledWith(0, 23); } finally { vi.useRealTimers(); } @@ -3695,6 +3840,151 @@ describe("TerminalViewport mounted lifecycle", () => { }, ); + it.each(["legacy", "owner", "mirror"] as const)( + "re-measures and repaints when a font finishes loading under an unchanged mount (%s)", + { timeout: 30_000 }, + async (mode) => { + // #348: a font that loads after the terminal measured its cell leaves xterm's cached + // cell size stale while the mount keeps its box and the ResizeObserver stays silent. + const fonts = stubDocumentFonts(); + if (mode !== "legacy") { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + } + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport({ visible: true }); + const terminal = view.fakeTerminal!; + const fitAddon = xtermState.fitAddons.at(-1)!; + const fit = fitAddon.fit; + // The fake grid changes only when fit runs, as xterm's does. + fit.mockImplementation(() => { + if (xtermState.proposedDimensions) { + terminal.cols = xtermState.proposedDimensions.cols; + terminal.rows = xtermState.proposedDimensions.rows; + } + }); + await view.flushFrame(); + await view.flushMountFit(); + let claim: string | undefined; + if (mode !== "legacy") { + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { + ...terminalSnapshot("prompt"), + size: { cols: 80, rows: 24, sizeClaim: mode === "owner" ? null : "other-window" }, + }, + }), + ); + if (mode === "owner") { + claim = view.resizeSpy.mock.calls.at(-1)![0].input.sizeClaim as string; + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 24, sizeClaim: claim! }, + }), + ); + } + } + expect(terminal.rows).toBe(24); + const measure = terminal._core._charSizeService.measure; + xtermState.proposedDimensions = { cols: 80, rows: 23 }; + view.resizeSpy.mockClear(); + fit.mockClear(); + fitAddon.proposeDimensions.mockClear(); + measure.mockClear(); + terminal.clearTextureAtlas.mockClear(); + terminal.refresh.mockClear(); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + await act(async () => fonts.dispatchEvent(new Event("loadingdone"))); + await view.flushFrame(); + // New cell metrics wait for the mount to settle, as a box change does (R16). + elapseMountSettle(); + await act(async () => vi.advanceTimersByTime(TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS)); + if (mode === "legacy") { + expect(fit).toHaveBeenCalledOnce(); + expect(terminal.rows).toBe(23); + expect(view.resizeSpy).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 80, + rows: 23, + structuredResult: true, + }, + }); + } else if (mode === "owner") { + expect(view.resizeSpy).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 80, + rows: 23, + sizeClaim: claim, + structuredResult: true, + }, + }); + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 23, sizeClaim: claim! }, + }), + ); + expect(terminal.rows).toBe(23); + } else { + // A mirror keeps the owner's grid and never claims on a font change. + expect(fit).not.toHaveBeenCalled(); + expect(view.resizeSpy).not.toHaveBeenCalled(); + expect(terminal.cols).toBe(80); + expect(terminal.rows).toBe(24); + } + await act(async () => vi.advanceTimersByTime(50)); + expect(terminal.clearTextureAtlas).toHaveBeenCalledOnce(); + // The repaint runs at 50 ms, on the grid before the settled claim; xterm redraws a resize. + expect(terminal.refresh).toHaveBeenCalledOnce(); + expect(terminal.refresh).toHaveBeenCalledWith(0, 23); + } finally { + vi.useRealTimers(); + } + // The cell is measured before any size is proposed; legacy's fit proposes inside. + expect(measure).toHaveBeenCalledOnce(); + expect(measure.mock.invocationCallOrder[0]).toBeLessThan( + firstCallOrder(fitAddon.proposeDimensions, fit), + ); + }, + ); + + it("does nothing when a font finishes loading after unmount", { timeout: 30_000 }, async () => { + const fonts = stubDocumentFonts(); + const view = await mountViewport({ visible: true }); + await view.flushFrame(); + await view.flushMountFit(); + const terminal = view.fakeTerminal!; + const fit = xtermState.fitAddons.at(-1)!.fit; + await unmount(view.mounted); + view.resizeSpy.mockClear(); + fit.mockClear(); + terminal._core._charSizeService.measure.mockClear(); + terminal.clearTextureAtlas.mockClear(); + terminal.refresh.mockClear(); + + await act(async () => fonts.dispatchEvent(new Event("loadingdone"))); + await view.flushFrame(); + + expect(terminal._core._charSizeService.measure).not.toHaveBeenCalled(); + expect(fit).not.toHaveBeenCalled(); + expect(terminal.clearTextureAtlas).not.toHaveBeenCalled(); + expect(terminal.refresh).not.toHaveBeenCalled(); + expect(view.resizeSpy).not.toHaveBeenCalled(); + }); + it("sends a height-only change of the mount to the PTY as the size owner", async () => { // #348: an Activity card appearing below an AI terminal shrinks only its height. testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; @@ -3773,6 +4063,57 @@ describe("TerminalViewport mounted lifecycle", () => { expect(terminal.refresh).toHaveBeenCalledWith(0, terminal.rows - 1); }); + describe("development size record (#348)", () => { + afterEach(() => { + setTerminalSizeDiagnosticsEnabled(false); + clearTerminalSizeDiagnostics(); + }); + + it("records size diagnostics without terminal content", { timeout: 30_000 }, async () => { + setTerminalSizeDiagnosticsEnabled(true); + clearTerminalSizeDiagnostics(); + const view = await mountViewport({ visible: true }); + await view.flushFrame(); + await view.flushMountFit(); + await act(async () => view.emitOutput("secret-marker-value")); + await view.flushFrame(); + expect(view.fakeTerminal!.writes.join("")).toContain("secret-marker-value"); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + await view.resizeContainer(); + await view.flushFrame(); + await act(async () => vi.advanceTimersByTime(50)); + } finally { + vi.useRealTimers(); + } + + const records = readTerminalSizeDiagnostics(); + const layout = records.find((entry) => entry.trigger === "layout"); + expect(layout).toBeDefined(); + for (const [field, value] of Object.entries(layout!)) + expect(value, field).not.toBeUndefined(); + expect(layout).toMatchObject({ + role: "legacy", + fitted: { cols: 80, rows: 24 }, + applied: { cols: 80, rows: 24 }, + renderer: "standard", + }); + expect(records.some((entry) => entry.repaint === "done")).toBe(true); + expect(JSON.stringify(records)).not.toContain("secret-marker-value"); + }); + + it("records no size diagnostics while disabled", { timeout: 30_000 }, async () => { + const view = await mountViewport({ visible: true }); + await view.flushFrame(); + await view.flushMountFit(); + await view.resizeContainer(); + await view.flushFrame(); + setTerminalSizeDiagnosticsEnabled(true); + expect(readTerminalSizeDiagnostics()).toEqual([]); + }); + }); + it("issues one resize when either exact dimension changes and preserves real transitions", async () => { const view = await mountViewport({ visible: true }); await view.flushFrame(); @@ -4640,6 +4981,99 @@ describe("TerminalViewport mounted lifecycle", () => { }); }); + it( + "repaints a renderer created after host config once the bundled font loads", + { timeout: 30_000 }, + async () => { + testState.serverConfig = null; + let resolveFont: () => void = () => undefined; + testState.fontLoad.mockImplementation( + () => new Promise((resolve) => (resolveFont = resolve)), + ); + const props = viewportProps({ + terminalId: "term-codex-config-loading", + command: decodeTerminalLaunchCommand({ executable: "codex.exe", args: [] })!, + }); + const mounted = await mount(); + expect(xtermState.terminals).toHaveLength(0); + testState.resizeCommand.mockClear(); + + testState.serverConfig = { + availableEditors: ["vscode"], + environment: { platform: { os: "windows" } }, + }; + const timerCallStart = beginComponentTimerCapture(); + await act(async () => mounted.root.render()); + captureComponentTimers(timerCallStart); + elapseMountSettle(); + await flushAnimationFrames(); + await flushComponentFitTimers(); + const terminal = xtermState.terminals.at(-1)!; + const fitAddon = xtermState.fitAddons.at(-1)!; + const measure = terminal._core._charSizeService.measure; + measure.mockClear(); + fitAddon.fit.mockClear(); + fitAddon.proposeDimensions.mockClear(); + terminal.clearTextureAtlas.mockClear(); + terminal.refresh.mockClear(); + + await act(async () => { + resolveFont(); + await Promise.resolve(); + }); + + expect(measure).toHaveBeenCalledOnce(); + expect(measure.mock.invocationCallOrder[0]).toBeLessThan( + firstCallOrder(fitAddon.proposeDimensions, fitAddon.fit), + ); + expect(terminal.clearTextureAtlas).toHaveBeenCalledOnce(); + expect(terminal.refresh).toHaveBeenCalledWith(0, terminal.rows - 1); + expect(testState.resizeCommand).toHaveBeenCalledOnce(); + }, + ); + + it( + "ignores a host config font load that resolves after its renderer was disposed", + { timeout: 30_000 }, + async () => { + testState.serverConfig = null; + let resolveFont: () => void = () => undefined; + testState.fontLoad.mockImplementation( + () => new Promise((resolve) => (resolveFont = resolve)), + ); + const props = viewportProps({ + terminalId: "term-codex-config-loading", + command: decodeTerminalLaunchCommand({ executable: "codex.exe", args: [] })!, + }); + const mounted = await mount(); + testState.serverConfig = { + availableEditors: ["vscode"], + environment: { platform: { os: "windows" } }, + }; + const timerCallStart = beginComponentTimerCapture(); + await act(async () => mounted.root.render()); + captureComponentTimers(timerCallStart); + const terminal = xtermState.terminals.at(-1)!; + expect(testState.fontLoad).toHaveBeenCalled(); + + await act(async () => mounted.root.render()); + expect(terminal.dispose).toHaveBeenCalled(); + const measure = terminal._core._charSizeService.measure; + measure.mockClear(); + terminal.clearTextureAtlas.mockClear(); + terminal.refresh.mockClear(); + + await act(async () => { + resolveFont(); + await Promise.resolve(); + }); + + expect(measure).not.toHaveBeenCalled(); + expect(terminal.clearTextureAtlas).not.toHaveBeenCalled(); + expect(terminal.refresh).not.toHaveBeenCalled(); + }, + ); + it("uses the current theme when a hidden Windows Codex terminal first attaches", async () => { testState.resolvedTheme = "dark"; const command = decodeTerminalLaunchCommand({ executable: "codex.exe", args: [] })!; @@ -4814,11 +5248,56 @@ describe("TerminalViewport mounted lifecycle", () => { expect(diagnosticSpy).toHaveBeenCalledOnce(); expect(diagnosticSpy).toHaveBeenCalledWith( "[terminal] WebGL renderer unavailable; using the standard renderer.", + { cause: "context lost", error: undefined }, ); + expect(JSON.stringify(diagnosticSpy.mock.calls)).not.toContain("sensitive"); expect(webglState.instances).toHaveLength(1); expect(view.fakeTerminal).not.toBeNull(); }); + it.each([false, true])( + "activates Unicode 11 on every terminal (WebGL %s)", + async (webglEnabled) => { + const view = await mountViewport({ visible: true, webglEnabled }); + const terminal = view.fakeTerminal!; + const loaded = () => terminal.loadAddon.mock.calls.map(([addon]) => addon); + expect(loaded()).toContainEqual(expect.objectContaining({ kind: "unicode11" })); + expect(terminal.unicode.activeVersion).toBe("11"); + if (webglEnabled) { + await view.settleWebgl(); + expect(loaded()).toContain(view.webglAddon); + } + }, + ); + + it("tries WebGL again in a new terminal after a context loss in the same viewport", async () => { + vi.spyOn(console, "warn").mockImplementation(() => undefined); + const view = await mountViewport({ visible: true, webglEnabled: true }); + await view.settleWebgl(); + view.webglAddon!.triggerContextLoss(); + const terminals = xtermState.terminals.length; + + await view.setVisible(false); + await view.setVisible(true); + expect(xtermState.terminals).toHaveLength(terminals + 1); + await view.settleWebgl(); + + expect(webglState.instances).toHaveLength(2); + }); + + it("names the WebGL fallback cause without raw error text", async () => { + const diagnosticSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const view = await mountViewport({ visible: true, webglEnabled: true, deferWebglImport: true }); + await view.rejectWebglImport(); + + expect(diagnosticSpy).toHaveBeenCalledOnce(); + expect(diagnosticSpy).toHaveBeenCalledWith( + "[terminal] WebGL renderer unavailable; using the standard renderer.", + { cause: "import failed", error: "Error" }, + ); + expect(JSON.stringify(diagnosticSpy.mock.calls)).not.toContain("sensitive"); + }); + it("contains synchronous context loss even when late listener cleanup throws", async () => { const diagnosticSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); webglState.listenerTriggersSynchronously = true; @@ -4931,28 +5410,32 @@ describe("TerminalViewport mounted lifecycle", () => { () => { webglState.constructorShouldThrow = true; }, + "addon creation failed", ], [ "xterm load", () => { xtermState.loadWebglShouldThrow = true; }, + "addon load failed", ], [ "activation", () => { webglState.activateShouldThrow = true; }, + "addon load failed", ], [ "context-listener registration", () => { webglState.listenerShouldThrow = true; }, + "addon load failed", ], ])( "falls back after a %s failure, diagnoses without raw errors, and does not retry", - async (_failure, configureFailure) => { + async (_failure, configureFailure, cause) => { const diagnosticSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); configureFailure(); const view = await mountViewport({ visible: true, webglEnabled: true }); @@ -4970,8 +5453,10 @@ describe("TerminalViewport mounted lifecycle", () => { expect(diagnosticSpy).toHaveBeenCalledOnce(); expect(diagnosticSpy).toHaveBeenCalledWith( "[terminal] WebGL renderer unavailable; using the standard renderer.", + { cause, error: "Error" }, ); expect(diagnosticSpy.mock.calls.flat().join(" ")).not.toContain("sensitive"); + expect(JSON.stringify(diagnosticSpy.mock.calls)).not.toContain("sensitive"); }, ); @@ -4994,6 +5479,7 @@ describe("TerminalViewport mounted lifecycle", () => { expect(webglState.instances).toHaveLength(0); expect(diagnosticSpy).toHaveBeenCalledOnce(); expect(diagnosticSpy.mock.calls.flat().join(" ")).not.toContain("sensitive"); + expect(JSON.stringify(diagnosticSpy.mock.calls)).not.toContain("sensitive"); }); it("allows a fresh viewport component to retry after a prior component failed", async () => { @@ -8435,6 +8921,7 @@ describe("TerminalViewport shared by windows on two machines (#348)", () => { testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true, terminalUserInputClaim: true, + terminalSizeHandOver: true, }; server = new FakeTerminalManager(); machines = []; @@ -8741,6 +9228,45 @@ describe("TerminalViewport shared by windows on two machines (#348)", () => { expect(document.activeElement).not.toBe(terminal.textarea); }); + /** Fit to this window, pressed twice against a server that declines the claim. */ + const fitDeclinedTwice = async () => { + const { host, client } = await serverHostOwnsTallerGrid(); + focusableXterm(client); + testState.resizeCommand.mockImplementation(async () => + AsyncResult.success({ + outcome: "claimNotRegistered", + size: server.size(), + generation: null, + sequence: null, + } satisfies TerminalResizeResult), + ); + await browserClick(fitButton(client)!); + const requests = testState.resizeCommand.mock.calls.length; + await browserClick(fitButton(client)!); + return { host, client, requests }; + }; + + const sizeNotice = (machine: Machine) => + Array.from(machine.mounted.container.querySelectorAll('[role="status"]')).find((element) => + element.textContent?.includes("Sized for another window."), + ); + + it("says the server kept this size when Fit to this window sends nothing", async () => { + const { client, requests } = await fitDeclinedTwice(); + expect(sizeNotice(client)?.textContent).toContain("The server kept this size."); + expect(testState.resizeCommand.mock.calls.length).toBe(requests); + expect(document.activeElement).toBe(fitButton(client)); + }); + + it("drops the server kept this size sentence when the applied size changes", async () => { + const { host, client } = await fitDeclinedTwice(); + expect(sizeNotice(client)?.textContent).toContain("The server kept this size."); + server.resize(claimOf(host), 120, 40); + await server.deliver(); + expect(view(client)).toMatchObject({ grid: "120x40", notice: true }); + expect(sizeNotice(client)?.textContent).not.toContain("The server kept this size."); + }); + // Real-pair case 5 on builds before the authoritative protocol: after the client took the // size and the host took it back by a keypress, a click on the client did not take it back. it.each([ @@ -9177,6 +9703,125 @@ describe("TerminalViewport shared by windows on two machines (#348)", () => { expect(view(owner)).toMatchObject({ grid: "121x26", owner: "this window" }); }); + it( + "sends only the final size after a re-attach, a font-metrics change and an Activity dock change (re-attach transient)", + { timeout: 30_000 }, + async () => { + // R16 (#348), decided by this test. The dock is not mounted here; its only effect on + // sizing is a shorter mount, which `resizeMount` gives. + const fonts = stubDocumentFonts(); + setTerminalSizeDiagnosticsEnabled(true); + clearTerminalSizeDiagnostics(); + try { + hasFocus.mockReturnValue(true); + const owner = await openWindow(CLIENT, { cols: 85, rows: 30 }); + await resizeMount(owner, { width: 600, height: 430 }, { cols: 85, rows: 30 }); + await settle(); + const before = claimsOf(owner).length; + const recordsBefore = readTerminalSizeDiagnostics().length; + + // The font's new metrics fit more rows in the same box, then the dock opens. + owner.fit = { cols: 85, rows: 33 }; + await act(async () => fonts.dispatchEvent(new Event("loadingdone"))); + await flushAnimationFrames(); + await resizeMount(owner, { width: 600, height: 400 }, { cols: 85, rows: 30 }); + await settle(); + await settle(); + + const claims = claimsOf(owner).slice(before); + const requested = readTerminalSizeDiagnostics() + .slice(recordsBefore) + .flatMap((record) => + record.request?.outcome === "pending" + ? [`${record.request.cols}x${record.request.rows}`] + : [], + ); + const rows = claims.map((claim) => Number(claim.split("x")[1])); + const reverted = rows.some((row, index) => index > 0 && row !== rows[index - 1]); + expect(reverted, JSON.stringify(claims)).toBe(false); + if (claims.length > 0) expect(claims.at(-1)).toBe("85x30"); + expect(requested).toEqual(claims); + } finally { + setTerminalSizeDiagnosticsEnabled(false); + } + }, + ); + + /** Claims after `change` gives 33 rows and a dock then shortens the box to 30 rows. */ + const claimsAfterCellChangeAndDock = async ( + prepare: () => void, + change: () => Promise, + ) => { + hasFocus.mockReturnValue(true); + prepare(); + const owner = await openWindow(CLIENT, { cols: 85, rows: 30 }); + await resizeMount(owner, { width: 600, height: 430 }, { cols: 85, rows: 30 }); + await settle(); + const before = claimsOf(owner).length; + owner.fit = { cols: 85, rows: 33 }; + await change(); + await flushAnimationFrames(); + await resizeMount(owner, { width: 600, height: 400 }, { cols: 85, rows: 30 }); + await settle(); + await settle(); + return claimsOf(owner).slice(before); + }; + const expectOnlyFinalClaim = (claims: string[]) => { + const rows = claims.map((claim) => Number(claim.split("x")[1])); + const reverted = rows.some((row, index) => index > 0 && row !== rows[index - 1]); + expect(reverted, JSON.stringify(claims)).toBe(false); + if (claims.length > 0) expect(claims.at(-1)).toBe("85x30"); + }; + + it( + "sends only the final size after a device pixel ratio change and an Activity dock change (pixel-ratio transient)", + { timeout: 30_000 }, + async () => { + const listeners = new Set<() => void>(); + const claims = await claimsAfterCellChangeAndDock( + () => + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + matches: true, + media: query, + addEventListener: (_type: string, listener: () => void) => listeners.add(listener), + removeEventListener: (_type: string, listener: () => void) => + listeners.delete(listener), + })), + ), + async () => { + await act(async () => { + // A copy: each listener watches the new ratio by re-registering. + for (const listener of Array.from(listeners)) listener(); + }); + }, + ); + expectOnlyFinalClaim(claims); + }, + ); + + it( + "sends only the final size after the bundled font loads and an Activity dock change (font-effect transient)", + { timeout: 30_000 }, + async () => { + let resolveFont: () => void = () => undefined; + const claims = await claimsAfterCellChangeAndDock( + () => + testState.fontLoad.mockImplementation( + () => new Promise((resolve) => (resolveFont = resolve)), + ), + async () => { + await act(async () => { + resolveFont(); + await Promise.resolve(); + }); + }, + ); + expectOnlyFinalClaim(claims); + }, + ); + it("does not claim a 4 px mount whose stream attaches before its size first changes", async () => { hasFocus.mockReturnValue(true); nextMountSize = { width: 4, height: 400 }; diff --git a/apps/web/src/components/ThreadTerminalPanel.tsx b/apps/web/src/components/ThreadTerminalPanel.tsx index a190400dd3..59ecf96430 100644 --- a/apps/web/src/components/ThreadTerminalPanel.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.tsx @@ -100,8 +100,18 @@ import { isTerminalSizingMinimum, proposeTerminalDimensions, registerTerminalSizeReader, + remeasureTerminalCharSize, terminalHostHasArea, } from "./terminalSizing"; +import { activateTerminalUnicode, TERMINAL_BASE_OPTIONS } from "./terminalUnicode"; +import { + readTerminalSizeDiagnostics, + recordTerminalSizeDiagnostic, + setTerminalSizeDiagnosticsEnabled, + terminalSizeDiagnosticsEnabled, + type TerminalSizeDiagnosticRecord, + type TerminalSizeDiagnosticTrigger, +} from "./terminalSizeDiagnostics"; import { answersTerminalQueries, isUnownedUntilUsed, @@ -148,6 +158,18 @@ const terminalInputBindings = new Map(); // releases it alongside the retained input resources below. const dismissedTerminalSizes = new Map(); const TERMINAL_WRITE_INTERRUPTED = Symbol("terminal-write-interrupted"); +type WebglFailureStage = + | "context lost" + | "import failed" + | "addon creation failed" + | "addon load failed"; +// A developer turns the size record on and reads it from the console (#348). +if (import.meta.env.DEV && typeof window !== "undefined") { + (window as { __bibcodeTerminalSizeDiagnostics?: unknown }).__bibcodeTerminalSizeDiagnostics = { + enable: setTerminalSizeDiagnosticsEnabled, + read: readTerminalSizeDiagnostics, + }; +} interface TerminalInputBinding { renderer: { @@ -971,6 +993,9 @@ export function TerminalViewport({ // The server tells a user's input from the terminal's own replies (#348). const userInputClaim = sizeOwnership && serverConfig?.environment.capabilities?.terminalUserInputClaim === true; + // A released size waits for a window to be used only on a server that says it hands over. + const sizeHandOver = + sizeOwnership && serverConfig?.environment.capabilities?.terminalSizeHandOver === true; const terminalTargetKey = terminalInputKey(environmentId, threadId, terminalId); // One claim for this viewport's renderers: a renderer recreated when the window is hidden // and shown is the same window to the server, which gives a returning owner its size back. @@ -989,10 +1014,19 @@ export function TerminalViewport({ : null; // The size notice's scale (1 when the owner's grid fits unscaled), or null while hidden. const [mirroringSize, setMirroringSize] = useState(null); + // Fit to this window sent nothing: the server already kept this size (#348). + const [fitKept, setFitKept] = useState(false); const rendererRef = useRef<{ readonly sizeClaim: string; dismissSizeNotice(): void; + /** Records a decision made outside the renderer; callers check diagnostics are on. */ + diagnose( + trigger: TerminalSizeDiagnosticTrigger, + repaint: TerminalSizeDiagnosticRecord["repaint"], + ): void; fit(): void; + /** The cell was measured again: automatic claims wait for it to settle. */ + cellChanged(): void; render(signal: TerminalRenderSignal, claimOnAttach: boolean, exited: boolean): void; size(trigger: TerminalSizeTrigger): void; } | null>(null); @@ -1125,10 +1159,14 @@ export function TerminalViewport({ () => ({ cwd, worktreePath, command, runtimeEnvKey }), [cwd, worktreePath, command, runtimeEnvKey], ); - const recordWebglDiagnosticOnce = useCallback(() => { + // The stage and the error's type name only: raw error text can hold paths (#348). + const recordWebglDiagnosticOnce = useCallback((stage: WebglFailureStage, error?: unknown) => { if (webglDiagnosticRecordedRef.current) return; webglDiagnosticRecordedRef.current = true; - console.warn("[terminal] WebGL renderer unavailable; using the standard renderer."); + console.warn("[terminal] WebGL renderer unavailable; using the standard renderer.", { + cause: stage, + error: error instanceof Error ? error.name : error === undefined ? undefined : typeof error, + }); }, []); const handleSessionExited = useEffectEvent(() => { onSessionExited(); @@ -1422,11 +1460,7 @@ export function TerminalViewport({ const fitAddon = new FitAddon(); const terminal = new Terminal({ - cursorBlink: true, - macOptionClickForcesSelection: true, - lineHeight: 1, - fontSize: 12, - scrollback: 5_000, + ...TERMINAL_BASE_OPTIONS, fontFamily: readTerminalFontFamily(), theme: terminalThemeFromApp( readEffectiveTerminalTheme(), @@ -1487,6 +1521,7 @@ export function TerminalViewport({ }, }); terminal.loadAddon(fitAddon); + activateTerminalUnicode(terminal); terminal.open(mount); if (terminalHostHasArea(mount)) fitTerminalSafely(fitAddon); // A paused renderer can move the view by itself; the position it had when drawing @@ -1617,14 +1652,40 @@ export function TerminalViewport({ environmentId, input: { threadId, terminalId, ...size, structuredResult: true }, }); - const requestTerminalResize = (rendererGeneration: number, dimensions: TerminalDimensions) => { - if (resizeRendererGenerationRef.current !== rendererGeneration) return; + // A development-only record of size decisions (#348): numbers and enums, never content. + const diagnose = ( + trigger: TerminalSizeDiagnosticTrigger, + fitted: TerminalDimensions | null, + request: TerminalSizeDiagnosticRecord["request"] = null, + repaint: TerminalSizeDiagnosticRecord["repaint"] = "none", + ) => + recordTerminalSizeDiagnostic({ + trigger, + rendererGeneration: resizeRendererGeneration, + mountWidth: mount.clientWidth, + mountHeight: mount.clientHeight, + fitted, + applied: !sizeOwnership + ? { cols: terminal.cols, rows: terminal.rows } + : appliedSize && { cols: appliedSize.cols, rows: appliedSize.rows }, + role: !sizeOwnership ? "legacy" : appliedSize?.sizeClaim === sizeClaim ? "owner" : "mirror", + devicePixelRatio: globalThis.devicePixelRatio, + fontStatus: document.fonts?.status ?? "unavailable", + renderer: webglLifecycleRef.current !== null ? "webgl" : "standard", + request, + repaint, + }); + const requestTerminalResize = ( + rendererGeneration: number, + dimensions: TerminalDimensions, + ): boolean => { + if (resizeRendererGenerationRef.current !== rendererGeneration) return false; const current = lastRequestedSizeRef.current; if ( current?.generation === rendererGeneration && terminalDimensionsEqual(current.dimensions, dimensions) ) - return; + return false; const request = { generation: rendererGeneration, dimensions }; lastRequestedSizeRef.current = request; void resizeTerminal(dimensions).then((result) => { @@ -1633,6 +1694,11 @@ export function TerminalViewport({ lastRequestedSizeRef.current !== request ) return; + if (terminalSizeDiagnosticsEnabled()) + diagnose("resize-answer", null, { + ...dimensions, + outcome: result._tag === "Success" ? "applied" : "failed", + }); if (result._tag === "Success") { resizeRetries = 0; return; @@ -1640,6 +1706,7 @@ export function TerminalViewport({ lastRequestedSizeRef.current = null; retryFailedResize(); }); + return true; }; const resizeRendererGeneration = resizeRendererGenerationRef.current + 1; @@ -1649,6 +1716,9 @@ export function TerminalViewport({ fitAddonRef.current = fitAddon; rendererEpochRef.current += 1; setRendererEpoch(rendererEpochRef.current); + // A new terminal tries WebGL again, and reports its own failure (#348). + webglFailedRef.current = false; + webglDiagnosticRecordedRef.current = false; const rendererOwner = {}; const { binding: inputBinding, scheduler: inputScheduler } = acquireTerminalInputBinding(terminalTargetKey); @@ -1786,6 +1856,7 @@ export function TerminalViewport({ handleSizeTrigger(pending); }, settleRemaining); if (sizeOwnership) updateSizeNotice(); + if (terminalSizeDiagnosticsEnabled()) diagnose(trigger, fittedDimensions); return; } // A settled automatic trigger runs a deferred one now; an attach claim keeps its rule. @@ -1800,10 +1871,10 @@ export function TerminalViewport({ const wasAtBottom = followsOutput(); fitTerminalSafely(fitAddon); if (wasAtBottom) terminal.scrollToBottom(); - requestTerminalResize(resizeRendererGeneration, { - cols: terminal.cols, - rows: terminal.rows, - }); + const fittedGrid = { cols: terminal.cols, rows: terminal.rows }; + const sent = requestTerminalResize(resizeRendererGeneration, fittedGrid); + if (terminalSizeDiagnosticsEnabled()) + diagnose(trigger, fittedGrid, sent ? { ...fittedGrid, outcome: "pending" } : null); return; } // A claim already answered for this geometry waits for its acknowledgement; sending it @@ -1843,9 +1914,12 @@ export function TerminalViewport({ ) { layoutChangedWhileClaimPending = true; } + let sentClaim: TerminalSize | null = null; if (fitted && shouldClaim(fitted)) { const request = { ...fitted, sizeClaim }; + sentClaim = request; claimInFlight = request; + setFitKept(false); awaitedAcknowledgement = null; claimNoticeSuppressed = true; const finishClaim = (succeeded: boolean, result: TerminalResizeResult | null = null) => { @@ -1856,6 +1930,13 @@ export function TerminalViewport({ ) return; claimInFlight = null; + if (terminalSizeDiagnosticsEnabled()) + diagnose("resize-answer", fittedDimensions, { + cols: request.cols, + rows: request.rows, + // An older server answers null for a claim it took. + outcome: succeeded ? (result?.outcome ?? "applied") : "failed", + }); if (succeeded) { resizeRetries = 0; completeClaim(request, result); @@ -1880,6 +1961,12 @@ export function TerminalViewport({ ); } updateSizeNotice(fitted); + if (terminalSizeDiagnosticsEnabled()) + diagnose( + trigger, + fitted, + sentClaim && { cols: sentClaim.cols, rows: sentClaim.rows, outcome: "pending" }, + ); }; const applySize = ( size: TerminalSize | undefined, @@ -1898,10 +1985,11 @@ export function TerminalViewport({ const dismissed = dismissedTerminalSizes.get(terminalTargetKey); if (size && dismissed && !terminalSizesEqual(dismissed, size)) dismissedTerminalSizes.delete(terminalTargetKey); + if (!terminalSizesEqual(appliedSize, size)) setFitKept(false); appliedSize = size ?? null; if (size?.sizeClaim != null) unownedUntilUsed = false; // A window attaching to a size the server left un-owned waits to be used as well. - else if (isUnownedUntilUsed(size)) unownedUntilUsed = true; + else if (sizeHandOver && isUnownedUntilUsed(size)) unownedUntilUsed = true; if (size && !terminalDimensionsEqual(terminal, size)) { const wasAtBottom = followsOutput(); terminal.resize(size.cols, size.rows); @@ -1924,7 +2012,7 @@ export function TerminalViewport({ outputSink.barrier(() => { // An older server leaves every released size un-owned; one that hands it over does // so only when no remaining window is known to be in use. - unownedUntilUsed = userInputClaim && signal.size.sizeClaim === null; + unownedUntilUsed = sizeHandOver && signal.size.sizeClaim === null; applySize(signal.size, false, signal); }); } @@ -1983,6 +2071,7 @@ export function TerminalViewport({ fit() { const pending = claimInFlight; handleSizeTrigger("fit"); + setFitKept(claimInFlight === pending); // The sent claim hides the notice and its button, so focus moves where the keys go. if (claimInFlight !== null && claimInFlight !== pending) terminal.focus(); }, @@ -1990,6 +2079,18 @@ export function TerminalViewport({ if (appliedSize) dismissedTerminalSizes.set(terminalTargetKey, appliedSize); updateSizeNotice(); }, + diagnose( + trigger: TerminalSizeDiagnosticTrigger, + repaint: TerminalSizeDiagnosticRecord["repaint"], + ) { + diagnose(trigger, fittedDimensions, null, repaint); + }, + cellChanged() { + // New cell metrics change the fit as a box change does, so the claim waits as well: + // a dock opening next must not follow a claim it reverts (#348). + mountSizeChangedAt = performance.now(); + mountUnsettledSince ??= mountSizeChangedAt; + }, }; rendererRef.current = renderer; @@ -2455,6 +2556,7 @@ export function TerminalViewport({ if (disposed) return; disposed = true; updateMirroringSize(null); + setFitKept(false); resizeRendererGenerationRef.current += 1; lastRequestedSizeRef.current = null; @@ -2524,6 +2626,7 @@ export function TerminalViewport({ sizeOwnership, orderedInput, userInputClaim, + sizeHandOver, viewportSizeClaim, ]); @@ -2562,7 +2665,14 @@ export function TerminalViewport({ useEffect(() => { const terminal = terminalRef.current; const fitAddon = fitAddonRef.current; - if (!shouldRender || terminal === null || fitAddon === null) return; + // A renderer created after this effect last ran is repainted once its epoch commits. + if ( + !shouldRender || + terminal === null || + fitAddon === null || + rendererEpoch !== rendererEpochRef.current + ) + return; terminal.options.fontFamily = terminalFontFamily; const rendererGeneration = resizeRendererGenerationRef.current; @@ -2578,15 +2688,18 @@ export function TerminalViewport({ return; } + // xterm measured the cell before the font loaded, or not at all for an equal family. + if (remeasureTerminalCharSize(terminal)) rendererRef.current?.cellChanged(); terminal.clearTextureAtlas(); rendererRef.current?.size("layout"); terminal.refresh(0, terminal.rows - 1); + if (terminalSizeDiagnosticsEnabled()) rendererRef.current?.diagnose("font", "done"); }); return () => { cancelled = true; }; - }, [shouldRender, terminalFontFamily]); + }, [shouldRender, terminalFontFamily, rendererEpoch]); useEffect(() => { const terminal = terminalRef.current; @@ -2613,13 +2726,16 @@ export function TerminalViewport({ const isCurrent = () => !cleanedUp && webglGenerationRef.current === generation && terminalRef.current === terminal; + let stage: WebglFailureStage = "import failed"; void (async () => { try { const { WebglAddon } = await loadTerminalWebglAddon(); if (!isCurrent()) return; + stage = "addon creation failed"; const addon = new WebglAddon(); lifecycle = createWebglLifecycle(addon, terminal); + stage = "addon load failed"; terminal.loadAddon(addon); if (!isCurrent()) { lifecycle.dispose(); @@ -2648,7 +2764,7 @@ export function TerminalViewport({ if (webglLifecycleRef.current === activeLifecycle) { webglLifecycleRef.current = null; } - recordWebglDiagnosticOnce(); + recordWebglDiagnosticOnce("context lost"); }); lifecycle.setContextLossDisposable(contextLossDisposable); if (!isCurrent() || lifecycle.disposed) { @@ -2657,7 +2773,7 @@ export function TerminalViewport({ } webglLifecycleRef.current = lifecycle; - } catch { + } catch (error) { lifecycle?.dispose(); if (!isCurrent()) return; @@ -2665,7 +2781,7 @@ export function TerminalViewport({ if (webglLifecycleRef.current === lifecycle) { webglLifecycleRef.current = null; } - recordWebglDiagnosticOnce(); + recordWebglDiagnosticOnce(stage, error); } })(); @@ -2844,7 +2960,9 @@ export function TerminalViewport({ } terminal.clearTextureAtlas(); terminal.refresh(0, terminal.rows - 1); + if (terminalSizeDiagnosticsEnabled()) rendererRef.current?.diagnose("repaint", "done"); }, 50); + if (terminalSizeDiagnosticsEnabled()) rendererRef.current?.diagnose("repaint", "scheduled"); }); }; const observer = new ResizeObserver(scheduleResize); @@ -2861,11 +2979,23 @@ export function TerminalViewport({ }; function handlePixelRatioChange() { watchPixelRatio(); + if (terminalSizeDiagnosticsEnabled()) rendererRef.current?.diagnose("pixel-ratio", "none"); + // Rounded rows change the fit as a box change does, so the claim waits as well (#348). + rendererRef.current?.cellChanged(); scheduleResize(); } watchPixelRatio(); + // A font that loads later changes the cell without changing the box (#348). + const fonts = typeof document.fonts?.addEventListener === "function" ? document.fonts : null; + const handleFontLoadingDone = () => { + const terminal = terminalRef.current; + if (terminal && remeasureTerminalCharSize(terminal)) rendererRef.current?.cellChanged(); + scheduleResize(); + }; + fonts?.addEventListener("loadingdone", handleFontLoadingDone); return () => { + fonts?.removeEventListener("loadingdone", handleFontLoadingDone); pixelRatioQuery?.removeEventListener("change", handlePixelRatioChange); observer.disconnect(); workspace?.removeEventListener(CENTER_PANEL_RESIZE_END_EVENT, scheduleResize); @@ -2914,6 +3044,7 @@ export function TerminalViewport({ Sized for another window. {mirroringSize < 1 ? ` Shown at ${Math.floor(mirroringSize * 100)}%.` : ""} + {fitKept ? " The server kept this size." : ""} + ); diff --git a/apps/web/src/components/files/unsavedFileEdits.test.ts b/apps/web/src/components/files/unsavedFileEdits.test.ts index d2ab28f3a5..3c3284c191 100644 --- a/apps/web/src/components/files/unsavedFileEdits.test.ts +++ b/apps/web/src/components/files/unsavedFileEdits.test.ts @@ -138,12 +138,24 @@ describe("unsaved file edits across the window", () => { }); describe("desktop close requests", () => { + let endInstall: (() => void) | null = null; + afterEach(() => { + // Module state outlives a test: end any install a test left blocked. + endInstall?.(); + endInstall = null; + window.sessionStorage.clear(); + }); + function fakeBridge() { let listener: ((request: DesktopCloseRequest) => void) | null = null; const replies: [number, string][] = []; return { replies, send: (request: DesktopCloseRequest) => listener?.(request), + onUpdateInstallEnded: (next: () => void) => { + endInstall = next; + return () => {}; + }, onCloseRequest: (next: (request: DesktopCloseRequest) => void) => { listener = next; return () => { @@ -200,6 +212,21 @@ describe("unsaved file edits across the window", () => { await vi.waitFor(() => expect(bridge.replies.at(-1)).toEqual([6, "stay"])); }); + it("a source registered during an update install is held until the install ends", async () => { + register(fakeSource()); + const bridge = fakeBridge(); + cleanups.push(respondToDesktopCloseRequests(bridge)); + bridge.send({ requestId: 11, reason: "update" }); + await vi.waitFor(() => expect(bridge.replies.at(-1)).toEqual([11, "allow"])); + + const late = register(fakeSource()); + expect(late.holdSavingForLeave).toHaveBeenCalledOnce(); + expect(late.release).not.toHaveBeenCalled(); + endInstall!(); + endInstall = null; + expect(late.release).toHaveBeenCalledOnce(); + }); + it("Save all answers allow once every file is saved", async () => { const source = register(fakeSource(["notes.md"])); const bridge = fakeBridge(); diff --git a/apps/web/src/components/files/unsavedFileEdits.ts b/apps/web/src/components/files/unsavedFileEdits.ts index 2ebec07bd2..fbc4f4217a 100644 --- a/apps/web/src/components/files/unsavedFileEdits.ts +++ b/apps/web/src/components/files/unsavedFileEdits.ts @@ -1,4 +1,9 @@ -import type { DesktopBridge, DesktopCloseReply, DesktopCloseRequest } from "@bibcode/contracts"; +import type { + DesktopBridge, + DesktopCloseReply, + DesktopCloseRequest, + DesktopUpdateState, +} from "@bibcode/contracts"; import { toastManager } from "../ui/toast"; @@ -32,10 +37,12 @@ let discardHolds: (() => void)[] = []; * without restarting. Saving is held and the installing dialog keeps edits out, so no edit made * during the install is lost when it restarts the app. */ -let updateInstall: { readonly release: () => void } | null = null; +let updateInstall: { readonly holds: (() => void)[]; readonly release: () => void } | null = null; export function registerUnsavedSource(source: UnsavedSource): () => void { sources.add(source); + // A registry that mounts during the install is held like the ones that were there before it. + updateInstall?.holds.push(source.holdSavingForLeave()); return () => { sources.delete(source); }; @@ -80,10 +87,102 @@ export function isUpdateInstalling(): boolean { return updateInstall !== null; } +/** Kept per window, so a reload during an allowed install restores the block before any save. */ +const INSTALL_MARKER_KEY = "bibcode:update-install-allowed"; + +function readInstallMarker(): boolean { + try { + return window.sessionStorage.getItem(INSTALL_MARKER_KEY) === "1"; + } catch { + return false; + } +} + +function writeInstallMarker(on: boolean): void { + try { + if (on) window.sessionStorage.setItem(INSTALL_MARKER_KEY, "1"); + else window.sessionStorage.removeItem(INSTALL_MARKER_KEY); + } catch { + // An unusable sessionStorage means no marker: a reload then cannot restore the block. + } +} + +/** The desktop sets `phase` to protecting or installing only after the window allowed it. */ +function isRemoteInstallRunning(state: DesktopUpdateState | null | undefined): boolean { + return ( + state?.requestedBy != null && (state.phase === "protecting" || state.phase === "installing") + ); +} + +/** + * A confirmed state starts or ends the block. Only a cleared requester outside protecting and + * installing ends it: between the allow and the backup the phase still reads "available" with the + * requester set, and a completed install clears the requester with the phase left at installing + * while the app restarts. + */ +function settleInstallState(state: DesktopUpdateState | null | undefined): void { + if (isRemoteInstallRunning(state)) blockForUpdateInstall(); + else if ( + state?.requestedBy == null && + state?.phase !== "protecting" && + state?.phase !== "installing" && + updateInstall !== null + ) { + endUpdateInstall(); + } +} + +const INSTALL_STATE_TIMEOUT_MS = 5_000; +const INSTALL_STATE_RETRY_MS = 2_000; + +/** + * Asks the desktop for its update state until it answers. A rejection or a timeout is not an + * answer: it changes nothing and asks again. Returns the function that stops asking. + */ +function askInstallState( + bridge: Pick, + onAnswer: (state: DesktopUpdateState | null | undefined) => void, + onAttemptFailed?: () => void, +): () => void { + let stopped = false; + let warned = false; + let timer: ReturnType | undefined; + const attempt = () => { + let settled = false; + const fail = (error: unknown) => { + if (settled || stopped) return; + settled = true; + clearTimeout(timer); + if (!warned) { + warned = true; + console.warn("[unsaved-file-edits] could not read the desktop update state", error); + } + timer = setTimeout(attempt, INSTALL_STATE_RETRY_MS); + onAttemptFailed?.(); + }; + timer = setTimeout(() => fail(new Error("no answer in time")), INSTALL_STATE_TIMEOUT_MS); + new Promise((resolve) => resolve(bridge.getUpdateState())).then((state) => { + // An answer after its own timeout or after stopping is stale. + if (settled || stopped) return; + settled = true; + stopped = true; + clearTimeout(timer); + onAnswer(state); + }, fail); + }; + attempt(); + return () => { + stopped = true; + clearTimeout(timer); + }; +} + function blockForUpdateInstall(): void { if (updateInstall !== null) return; + writeInstallMarker(true); const holds = [...sources].map((source) => source.holdSavingForLeave()); updateInstall = { + holds, release: () => { for (const release of holds) release(); }, @@ -91,9 +190,24 @@ function blockForUpdateInstall(): void { notifyQuestionListeners(); } +/** The installing dialog's button: set while a close-request subscription can ask the desktop. */ +let askInstallStateAgain: (() => Promise) | null = null; + +/** What a Check again found: the install still running, no answer, or nothing to report. */ +export type RecheckResult = "installing" | "unreachable" | null; + +/** + * Asks the desktop for its update state once more. Resolves after that attempt, answered or not; + * a failed attempt changes nothing and keeps asking. + */ +export function recheckUpdateInstall(): Promise { + return askInstallStateAgain?.() ?? Promise.resolve(null); +} + /** The desktop install ended without a restart: saving and editing run again. */ function endUpdateInstall(): void { releaseLeaveHolds(); + writeInstallMarker(false); const install = updateInstall; if (install === null) return; updateInstall = null; @@ -204,10 +318,35 @@ const KEEPALIVE_INTERVAL_MS = 1_000; * while this subscription holds a request, and stopping it with one pending answers stay. */ export function respondToDesktopCloseRequests( - bridge: Pick, + bridge: Pick & + Partial>, ): () => void { - const { onCloseRequest, replyCloseRequest, onUpdateInstallEnded } = bridge; + const { onCloseRequest, replyCloseRequest, onUpdateInstallEnded, getUpdateState, onUpdateState } = + bridge; if (!onCloseRequest || !replyCloseRequest) return () => {}; + let stopAsking = () => {}; + const askAgain = () => + new Promise((resolve) => { + stopAsking(); + if (!getUpdateState) { + settleInstallState(null); + resolve(null); + return; + } + const stop = askInstallState( + bridge as Pick, + (state) => { + settleInstallState(state); + resolve(updateInstall === null ? null : "installing"); + }, + () => resolve("unreachable"), + ); + // A push or an allow that stops this ask also settles a pending Check again. + stopAsking = () => { + stop(); + resolve(null); + }; + }); let active: { readonly requestId: number; readonly reason: DesktopCloseRequest["reason"]; @@ -237,11 +376,29 @@ export function respondToDesktopCloseRequests( void askBeforeLeaving(reason).then((answer) => { if (active !== current) return; // Only a remote install asks with "update"; it keeps running long after this answer. - if (answer === "proceed" && reason === "update") blockForUpdateInstall(); + if (answer === "proceed" && reason === "update") { + // An answer to a query sent before this allow would be stale. + stopAsking(); + blockForUpdateInstall(); + } finish(answer === "proceed" ? "allow" : "stay"); }); }); + askInstallStateAgain = askAgain; + const stopUpdateState = onUpdateState?.((state) => { + stopAsking(); + settleInstallState(state); + }); + if (getUpdateState) { + // The reload of a window that allowed an install: hold saving before anything can run. + if (readInstallMarker()) blockForUpdateInstall(); + void askAgain(); + } else if (updateInstall === null) { + // Nothing can confirm an install on this desktop, so it counts as not installing. + writeInstallMarker(false); + } const stopEnded = onUpdateInstallEnded?.(() => { + stopAsking(); // The person forced the install from the native prompt while this question was open; a late // answer must not start a block that no signal will end. if (active?.reason === "update") { @@ -254,6 +411,9 @@ export function respondToDesktopCloseRequests( return () => { unsubscribe(); stopEnded?.(); + stopUpdateState?.(); + stopAsking(); + if (askInstallStateAgain === askAgain) askInstallStateAgain = null; if (active === null) return; finish("stay"); question?.answer("cancel"); diff --git a/apps/web/src/components/gitManager/GitManagerToolbar.test.tsx b/apps/web/src/components/gitManager/GitManagerToolbar.test.tsx index c340a710fb..257fba141a 100644 --- a/apps/web/src/components/gitManager/GitManagerToolbar.test.tsx +++ b/apps/web/src/components/gitManager/GitManagerToolbar.test.tsx @@ -244,6 +244,8 @@ beforeEach(() => { return { cancel: vi.fn(), result: Promise.resolve({ _tag: "Success", value: event }) }; }); useGitManagerStore.setState({ byProjectKey: {} }); + // The branch list renders only while its picker is open (#360); these cases click its rows. + useGitManagerStore.getState().setOpenDropdown(currentProject, "branch"); }); describe("GitManagerToolbar", () => { @@ -348,6 +350,55 @@ describe("GitManagerToolbar", () => { }, ); + it("sends one checkout and one fetch when a branch is chosen and then fetched (#360)", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + h.snapshot = { ...refsSnapshot(), remoteBranches: [ref("origin/develop")] }; + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const click = async (text: string) => { + const button = [...document.querySelectorAll("button")].find((entry) => + entry.textContent?.includes(text), + ); + expect(button).toBeDefined(); + await act(async () => button!.click()); + }; + try { + await act(async () => + root.render( + undefined} + />, + ), + ); + await click("origin/develop"); + // Choosing the branch closes the picker; its rows are gone until it opens again. + expect( + useGitManagerStore.getState().selectToolbarViewState(currentProject).openDropdown, + ).toBe(null); + await click("Fetch origin"); + + expect(h.runOperation.mock.calls.map((call) => call[1].input._tag)).toEqual([ + "branch-checkout", + "fetch", + ]); + } finally { + await act(async () => root.unmount()); + container.remove(); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false; + } + }); + it.each([ [ "the stash saves nothing", diff --git a/apps/web/src/components/gitManager/toolbar/GitManagerBranchDropdown.hidden.dom.test.tsx b/apps/web/src/components/gitManager/toolbar/GitManagerBranchDropdown.hidden.dom.test.tsx new file mode 100644 index 0000000000..96cee857f7 --- /dev/null +++ b/apps/web/src/components/gitManager/toolbar/GitManagerBranchDropdown.hidden.dom.test.tsx @@ -0,0 +1,333 @@ +// @vitest-environment happy-dom + +import type { GitManagerRefEntry, ScopedProjectRef } from "@bibcode/contracts"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { useGitManagerStore } from "../../../gitManagerStore"; +import { GitManagerBranchDropdown } from "./GitManagerBranchDropdown"; + +/** + * The real LegendList inside the real kept-mounted Popover (#360). happy-dom lays nothing out, so + * the test says what each element measures: 0 by 0 under a hidden ancestor, as a browser reports + * it; otherwise a 30 px row, or the 240 px list viewport. + */ +const ROW_HEIGHT = 30; +const VIEWPORT_HEIGHT = 240; +const ROW_SELECTOR = '[class*="h-[30px]"]'; + +const projectRef = { + environmentId: "environment-1", + projectId: "project-1", +} as ScopedProjectRef; + +function branches(count: number, generation: number): GitManagerRefEntry[] { + return Array.from({ length: count }, (_, index) => ({ + name: `branch-${index.toString().padStart(5, "0")}`, + tipSha: `sha-${generation}-${index}`, + upstream: null, + ahead: 0, + behind: 0, + current: index === generation, + isDefault: index === 0, + worktreePath: null, + blocked: [], + })); +} + +const originalRect = HTMLElement.prototype.getBoundingClientRect; +const originalStackTraceLimit = Error.stackTraceLimit; +let hiddenMeasurements = 0; +let container: HTMLDivElement; +let root: Root | null; +let uncaught: unknown[]; +let onSelectBranch = vi.fn(); + +/** An exit transition the test finishes by hand; Base UI waits on its `finished` promise. */ +let exitTransition: { finish: () => void; animation: Animation } | null = null; +const originalGetAnimations = Object.getOwnPropertyDescriptor(Element.prototype, "getAnimations"); + +function startExitTransition() { + let finish = () => undefined as void; + const finished = new Promise((resolve) => { + finish = resolve; + }); + const animation = { + finished, + pending: false, + playState: "running", + effect: { getTiming: () => ({ duration: 150, iterations: 1 }) }, + } as unknown as Animation; + exitTransition = { finish, animation }; +} + +function stubAnimations() { + Object.defineProperty(Element.prototype, "getAnimations", { + configurable: true, + value(this: Element) { + return exitTransition !== null && this.matches('[data-slot="popover-popup"]') + ? [exitTransition.animation] + : []; + }, + }); +} + +function stubMeasurements() { + HTMLElement.prototype.getBoundingClientRect = function (this: HTMLElement) { + if (this.closest("[hidden]") !== null) { + hiddenMeasurements += 1; + return DOMRect.fromRect({ width: 0, height: 0 }); + } + const isRow = this.matches(ROW_SELECTOR) || this.querySelectorAll(ROW_SELECTOR).length === 1; + return DOMRect.fromRect({ width: 512, height: isRow ? ROW_HEIGHT : VIEWPORT_HEIGHT }); + }; +} + +/** + * happy-dom's ResizeObserver never reports. This one reports, like a browser after layout, every + * observed element whose border box changed since its last report, and its first box. + */ +const observed = new Map(); +class ReportingResizeObserver { + constructor(private readonly callback: ResizeObserverCallback) {} + observe(target: Element) { + observed.set(target, { callback: this.callback, last: null }); + } + unobserve(target: Element) { + observed.delete(target); + } + disconnect() { + for (const [target, entry] of observed) { + if (entry.callback === this.callback) observed.delete(target); + } + } +} +globalThis.ResizeObserver = ReportingResizeObserver as unknown as typeof ResizeObserver; + +function reportResizes() { + const byCallback = new Map(); + for (const [target, entry] of observed) { + if (!target.isConnected) continue; + const rect = target.getBoundingClientRect(); + const box = `${rect.width}x${rect.height}`; + if (box === entry.last) continue; + entry.last = box; + const size = [{ blockSize: rect.height, inlineSize: rect.width }]; + const entries = byCallback.get(entry.callback) ?? []; + entries.push({ + target, + contentRect: rect, + borderBoxSize: size, + contentBoxSize: size, + devicePixelContentBoxSize: size, + } as unknown as ResizeObserverEntry); + byCallback.set(entry.callback, entries); + } + for (const [callback, entries] of byCallback) callback(entries, {} as ResizeObserver); +} + +async function settle() { + for (let frame = 0; frame < 5; frame += 1) { + await act(() => new Promise((resolve) => setTimeout(resolve, 20))); + await act(async () => reportResizes()); + } +} + +async function renderDropdown(refs: ReadonlyArray, currentBranchName: string) { + await act(async () => + root?.render( + , + ), + ); + await settle(); +} + +async function typeFilter(value: string) { + const input = document.querySelector('input[type="search"]'); + if (input === null) throw new Error("Missing branch filter"); + // React tracks the last value it set; go through the native setter so onChange sees a change. + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + await act(async () => { + setter?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + await settle(); +} + +function branchRowNames(): string[] { + return [...document.querySelectorAll(`${ROW_SELECTOR} .font-mono`)].map( + (element) => element.textContent ?? "", + ); +} + +function trigger(): HTMLButtonElement { + const button = document.querySelector('[aria-label="Choose branch"]'); + if (button === null) throw new Error("Missing branch trigger"); + return button; +} + +async function toggle() { + await act(async () => trigger().click()); + await settle(); +} + +function positioner(): HTMLElement { + const element = document.querySelector('[data-slot="popover-positioner"]'); + if (element === null) throw new Error("Missing popover positioner"); + return element; +} + +function renderedRows(): number { + return document.querySelectorAll(ROW_SELECTOR).length; +} + +/** Names each failure by its message and whether it came from the list's layout measurement. */ +function failures(thrown: unknown[]): string[] { + return [...thrown, ...uncaught].map((error) => + error instanceof Error + ? `${error.message.split(".")[0]} | from measureContainersInLayoutEffect: ${String( + error.stack?.includes("measureContainersInLayoutEffect"), + )}` + : String(error), + ); +} + +async function attempt(step: () => Promise, thrown: unknown[]) { + try { + await step(); + } catch (error) { + thrown.push(error); + } +} + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + // Deep enough to show whether an error came from the list's layout measurement. + Error.stackTraceLimit = 50; + useGitManagerStore.setState({ byProjectKey: {}, toolbarByProjectKey: {} }); + hiddenMeasurements = 0; + onSelectBranch = vi.fn(); + observed.clear(); + uncaught = []; + stubMeasurements(); + exitTransition = null; + stubAnimations(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container, { + onUncaughtError: (error) => uncaught.push(error), + onCaughtError: (error) => uncaught.push(error), + }); +}); + +afterEach(async () => { + await act(async () => root?.unmount()); + root = null; + container.remove(); + document.body.replaceChildren(); + HTMLElement.prototype.getBoundingClientRect = originalRect; + Error.stackTraceLimit = originalStackTraceLimit; + if (originalGetAnimations) { + Object.defineProperty(Element.prototype, "getAnimations", originalGetAnimations); + } else { + Reflect.deleteProperty(Element.prototype, "getAnimations"); + } + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false; +}); + +describe("GitManagerBranchDropdown with the real list and popover (#360)", () => { + it("a closed branch picker survives a refs update with thousands of branches", async () => { + const thrown: unknown[] = []; + await renderDropdown(branches(4_000, 1), "branch-00001"); + await toggle(); + expect(positioner().hidden).toBe(false); + const rowsWhileOpen = renderedRows(); + expect(rowsWhileOpen).toBeGreaterThan(0); + await toggle(); + expect(positioner().hidden).toBe(true); + + // A checkout or a fetch replaces the refs while the picker is closed. + await attempt(() => renderDropdown(branches(4_000, 2), "branch-00002"), thrown); + + expect(failures(thrown)).toEqual([]); + // Rows that measure 0 by 0 must not widen the hidden list's rendered range. + expect(renderedRows()).toBeLessThanOrEqual(rowsWhileOpen); + }); + + it("renders and measures no branch row while closed, and reopens with its filter and the current branches", async () => { + await renderDropdown(branches(4_000, 1), "branch-00001"); + await toggle(); + await typeFilter("branch-039"); + expect(branchRowNames().length).toBeGreaterThan(0); + await toggle(); + expect(positioner().hidden).toBe(true); + + hiddenMeasurements = 0; + const refreshed = [...branches(4_000, 2), { ...branches(1, 2)[0]!, name: "branch-039-new" }]; + await renderDropdown(refreshed, "branch-00002"); + + expect(renderedRows()).toBe(0); + expect(hiddenMeasurements).toBe(0); + + await toggle(); + expect(positioner().hidden).toBe(false); + expect(document.querySelector('input[type="search"]')?.value).toBe( + "branch-039", + ); + const names = branchRowNames(); + expect(names.length).toBeGreaterThan(0); + expect(names.every((name) => name.includes("branch-039"))).toBe(true); + + await typeFilter("branch-039-new"); + expect(branchRowNames()).toEqual(["branch-039-new"]); + const row = [...document.querySelectorAll("button")].find( + (button) => button.textContent === "branch-039-new", + ); + await act(async () => row?.click()); + await settle(); + expect(onSelectBranch).toHaveBeenCalledTimes(1); + expect(onSelectBranch.mock.calls[0]?.[0]).toMatchObject({ name: "branch-039-new" }); + expect(positioner().hidden).toBe(true); + expect(uncaught).toEqual([]); + }); + + it("keeps the rows through the closing transition and drops them once it has finished", async () => { + await renderDropdown(branches(4_000, 1), "branch-00001"); + await toggle(); + const rowsWhileOpen = renderedRows(); + expect(rowsWhileOpen).toBeGreaterThan(0); + + startExitTransition(); + await toggle(); + // The popup is still fading out: its rows stay so it does not empty first. + expect(positioner().hidden).toBe(false); + expect(renderedRows()).toBe(rowsWhileOpen); + + await act(async () => exitTransition?.finish()); + await settle(); + expect(positioner().hidden).toBe(true); + expect(renderedRows()).toBe(0); + + hiddenMeasurements = 0; + await renderDropdown(branches(4_000, 2), "branch-00002"); + expect(renderedRows()).toBe(0); + expect(hiddenMeasurements).toBe(0); + expect(uncaught).toEqual([]); + }); +}); diff --git a/apps/web/src/components/gitManager/toolbar/GitManagerBranchDropdown.test.tsx b/apps/web/src/components/gitManager/toolbar/GitManagerBranchDropdown.test.tsx index 566654045b..3a3c328422 100644 --- a/apps/web/src/components/gitManager/toolbar/GitManagerBranchDropdown.test.tsx +++ b/apps/web/src/components/gitManager/toolbar/GitManagerBranchDropdown.test.tsx @@ -100,6 +100,8 @@ async function renderDropdown( beforeEach(() => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; useGitManagerStore.setState({ byProjectKey: {}, toolbarByProjectKey: {} }); + // The list renders only while the picker is open (#360); these cases inspect its rows. + useGitManagerStore.getState().setOpenDropdown(projectRef, "branch"); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -182,13 +184,13 @@ describe("GitManagerBranchDropdown", () => { blocked: [{ operation: "branch-checkout", code: "worktree-checked-out", message }], }), ]); + expect(document.body.textContent).toContain("Switch to worktree"); + expect(document.body.textContent).toContain("/opaque/feature"); await act(async () => rowButton("feature").click()); expect(callbacks.onSwitchWorktree).toHaveBeenCalledWith("/opaque/feature"); expect(callbacks.onSelectBranch).not.toHaveBeenCalled(); - expect(document.body.textContent).toContain("Switch to worktree"); - expect(document.body.textContent).toContain("/opaque/feature"); }); it("renders a blocked row's server-authored message verbatim", async () => { diff --git a/apps/web/src/components/gitManager/toolbar/GitManagerBranchDropdown.tsx b/apps/web/src/components/gitManager/toolbar/GitManagerBranchDropdown.tsx index f50161682d..5c92a20fc4 100644 --- a/apps/web/src/components/gitManager/toolbar/GitManagerBranchDropdown.tsx +++ b/apps/web/src/components/gitManager/toolbar/GitManagerBranchDropdown.tsx @@ -447,10 +447,20 @@ export const GitManagerBranchDropdown = memo(function GitManagerBranchDropdown({ ], ); + const open = triggerDisabledReason === null && openDropdown === "branch"; + // The closed popup stays mounted and hidden, and a list in it would measure hidden rows + // (#360). The list stays through the closing transition and leaves once it has finished. + const [listMounted, setListMounted] = useState(open); + if (open && !listMounted) setListMounted(true); + const handleOpenChangeComplete = useCallback((nextOpen: boolean) => { + if (!nextOpen) setListMounted(false); + }, []); + return ( {listItems.length === 0 ? (

No branches found.

+ ) : !listMounted ? ( +
) : ( data={listItems} diff --git a/apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx b/apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx index 1e536706e6..a9c34f520b 100644 --- a/apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx +++ b/apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx @@ -14,6 +14,8 @@ const h = vi.hoisted(() => ({ navigate: vi.fn(), visibility: "visible" as DocumentVisibilityState, environment: null as unknown, + /** Renders of the volume badges, which the values view rebuilds whenever its body runs. */ + badgeRenders: 0, })); vi.mock("../ui/sheet", () => { @@ -34,6 +36,14 @@ vi.mock("../ui/tooltip", () => ({ TooltipTrigger: ({ render }: { render: ReactElement }) => render, TooltipPopup: () => null, })); +// A also reports an update when only its own element is new, even if the subtree +// bailed out, so the values view's renders are counted on a child it rebuilds on each run. +vi.mock("../ui/badge", () => ({ + Badge: ({ children }: { children?: ReactNode }) => { + h.badgeRenders += 1; + return {children}; + }, +})); vi.mock("~/hooks/useSettings", () => ({ useClientSettings: () => "locale" })); vi.mock("~/state/environments", () => ({ useEnvironment: () => h.environment })); vi.mock("~/state/server", () => ({ @@ -55,7 +65,12 @@ import { RegistryContext } from "@effect/atom-react"; import { AtomRegistry } from "effect/reactivity"; import { hostDashboardRequest } from "~/state/hostDashboard"; -import { HostDashboardCoordinator, HostDashboardSheet } from "./HostDashboardSheet"; +import { presentHostDashboard } from "./hostDashboard.logic"; +import { + HostDashboardCoordinator, + HostDashboardSheet, + HostDashboardValuesView, +} from "./HostDashboardSheet"; const ENVIRONMENT_ID = EnvironmentId.make("example-server"); @@ -321,6 +336,89 @@ describe("HostDashboardSheet", () => { } }); + describe("values view commits", () => { + const NOW_MS = Date.UTC(2026, 10, 11, 20, 0, 0); + + // The uptime in the "Since" heading ticks every second for the first minute; only that + // label may follow the sheet's own 1 s clock, not the whole values view (R18). + it("does not render the values view again on the sheet's clock while only the uptime ticks", async () => { + vi.useFakeTimers({ + now: NOW_MS, + toFake: ["Date", "setInterval", "clearInterval", "setTimeout", "clearTimeout"], + }); + const startedAt = new Date(NOW_MS - 10_000).toISOString(); + h.data = view({ startedAt, statisticsSince: startedAt }); + await render(); + const since = () => + container!.querySelector('[data-testid="host-dashboard-since"] h3')?.textContent; + const sinceAtMount = since(); + expect(sinceAtMount).toContain("up 10"); + h.badgeRenders = 0; + for (let tick = 0; tick < 3; tick += 1) { + await act(async () => vi.advanceTimersByTime(1_000)); + } + expect(since()).toBe(sinceAtMount!.replace("up 10", "up 13")); + expect(h.badgeRenders).toBe(0); + }); + + /** A fresh `values` object on every call, structurally equal for the same view. */ + function freshValues(reading: HostMetricsView) { + const values = presentHostDashboard({ + view: reading, + shownView: null, + connected: true, + supported: true, + failure: null, + lastItemAtMs: NOW_MS, + nowMs: NOW_MS, + timestampFormat: "locale", + }).values; + if (values === null) throw new Error("expected values"); + return values; + } + + /** Badge renders on each re-render after the mount; zero means the view did not run. */ + async function rendersAfterMount( + valuesFor: (render: number) => ReturnType, + ): Promise { + vi.useFakeTimers({ now: NOW_MS, toFake: ["Date"] }); + const reading = view(); + const target = createRoot(document.body.appendChild(document.createElement("div"))); + roots.push(target); + const counts: number[] = []; + for (let render = 0; render < 4; render += 1) { + h.badgeRenders = 0; + await act(async () => + target.render( + , + ), + ); + counts.push(h.badgeRenders); + } + expect(counts[0]).toBeGreaterThan(0); + return counts.slice(1); + } + + it("does not render the values view again when its props are structurally equal", async () => { + const reading = view(); + expect(await rendersAfterMount(() => freshValues(reading))).toEqual([0, 0, 0]); + }); + + it("renders the values view again when a reading changes", async () => { + const reading = view(); + const counts = await rendersAfterMount((render) => + render === 2 ? { ...freshValues(reading), cpu: "99.0%" } : freshValues(reading), + ); + expect(counts[0]).toBe(0); + expect(counts[1]).toBeGreaterThan(0); + expect(counts[2]).toBeGreaterThan(0); + }); + }); + it("dims the values after 3 seconds without an item", async () => { vi.useFakeTimers({ now: Date.UTC(2026, 10, 11, 20, 0, 0), diff --git a/apps/web/src/components/hostDashboard/HostDashboardSheet.tsx b/apps/web/src/components/hostDashboard/HostDashboardSheet.tsx index 084ff1a6a6..55238c856f 100644 --- a/apps/web/src/components/hostDashboard/HostDashboardSheet.tsx +++ b/apps/web/src/components/hostDashboard/HostDashboardSheet.tsx @@ -3,7 +3,17 @@ import type { HostMetricsView } from "@bibcode/client-runtime/state/server"; import type { EnvironmentId } from "@bibcode/contracts"; import type { ClientSettings } from "@bibcode/contracts/settings"; import { ChevronRightIcon } from "lucide-react"; -import { type JSX, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import * as Equal from "effect/Equal"; +import { + createContext, + type JSX, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; import { useClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; @@ -174,11 +184,13 @@ export function HostDashboardSheet({
- + + +
)}
@@ -208,15 +220,42 @@ function SectionHeading({ children, aside }: { children: string; aside?: string ); } -function HostDashboardValuesView({ - values, - view, - timestampFormat, -}: { +/** + * The "Since" heading's text, whose uptime ticks with the sheet's 1 s clock. It reaches the + * heading by context, so a tick renders the heading and not the memoized values view (R18). + */ +const HostDashboardSinceContext = createContext(null); + +function HostDashboardSinceHeading() { + const since = useContext(HostDashboardSinceContext); + return {since ?? "Since start"}; +} + +interface HostDashboardValuesViewProps { readonly values: HostDashboardValues; readonly view: HostMetricsView | null; readonly timestampFormat: ClientSettings["timestampFormat"]; -}) { +} + +// `presentHostDashboard` builds a fresh `values` on every 1 s tick, so compare it by content: +// only a changed reading renders the view again. `since` changes with the clock alone and is +// shown by `HostDashboardSinceHeading`, so it is left out (R18). +function valuesViewPropsEqual( + previous: HostDashboardValuesViewProps, + next: HostDashboardValuesViewProps, +): boolean { + return ( + previous.view === next.view && + previous.timestampFormat === next.timestampFormat && + Equal.equals({ ...previous.values, since: null }, { ...next.values, since: null }) + ); +} + +export const HostDashboardValuesView = memo(function HostDashboardValuesView({ + values, + view, + timestampFormat, +}: HostDashboardValuesViewProps) { const recent = view?.recent; const uptime = view?.uptime; const charts = useMemo( @@ -328,7 +367,7 @@ function HostDashboardValuesView({ )}
- {values.since ?? "Since start"} + {values.statistics.length === 0 ? null : ( @@ -373,4 +412,4 @@ function HostDashboardValuesView({ ); -} +}, valuesViewPropsEqual); diff --git a/apps/web/src/components/settings/remote-servers/ConnectTab.test.tsx b/apps/web/src/components/settings/remote-servers/ConnectTab.test.tsx index 61197a49d4..659bd0b151 100644 --- a/apps/web/src/components/settings/remote-servers/ConnectTab.test.tsx +++ b/apps/web/src/components/settings/remote-servers/ConnectTab.test.tsx @@ -793,7 +793,6 @@ function stubBrowserWindow(options?: { interface DesktopBridgeStub { applyServerExposure: ReturnType; - setTailscaleServeEnabled: ReturnType; setWslBackendEnabled: ReturnType; setWslDistro: ReturnType; setWslOnly: ReturnType; @@ -813,7 +812,6 @@ function createDesktopBridgeStub(): DesktopBridgeStub { }; return { applyServerExposure: vi.fn(async () => ({})), - setTailscaleServeEnabled: vi.fn(async () => ({})), setWslBackendEnabled: vi.fn(async () => wslState), setWslDistro: vi.fn(async () => wslState), setWslOnly: vi.fn(async () => wslState), @@ -2703,8 +2701,6 @@ describe("Remote Servers tabs", () => { mode: "network-accessible", endpointUrl: "https://desktop.example.com", advertisedHost: "desktop.local", - tailscaleServeEnabled: false, - tailscaleServePort: 443, }, advertisedEndpoints: [ endpoint({ @@ -2743,7 +2739,6 @@ describe("Remote Servers tabs", () => { id: "tailscale-magicdns:1", label: "Tailscale HTTPS", httpBaseUrl: "https://machine.tailnet.ts.net", - status: "unavailable", reachability: "private-network", hostedHttpsApp: "compatible", }), @@ -2759,7 +2754,8 @@ describe("Remote Servers tabs", () => { expect(markup).toContain("Version drift"); expect(markup).toContain("http://192.168.1.20:5133"); - expect(markup).toContain("Tailscale HTTPS"); + // A Tailscale Serve address set up outside BiBCode is listed like any other address. + expect(markup).toContain("https://machine.tailnet.ts.net"); expect(markup).toContain("BiBCode Connect"); expect(markup).toContain("Paired clients"); @@ -2783,45 +2779,11 @@ describe("Remote Servers tabs", () => { ); h.copyBehavior = "copy"; - // Only the Tailscale confirmation owns a restart control here. - for (const entry of findControls("button", "Restart and disable")) { - invoke(entry, "onClick"); - } - await flush(); - expect(bridge.setTailscaleServeEnabled).toHaveBeenCalledTimes(1); - expect(bridge.setTailscaleServeEnabled).toHaveBeenCalledWith({ enabled: false, port: 443 }); + // BiBCode does not manage Tailscale Serve: no switch, setup or disable control. + expect(findControls("switch", "Enable Tailscale HTTPS")).toHaveLength(0); + expect(findControls("button", "Setup")).toHaveLength(0); + expect(findControls("button", "Restart and disable")).toHaveLength(0); expect(bridge.applyServerExposure).not.toHaveBeenCalled(); - expect(h.refreshDesktopNetworkAccessState).toHaveBeenCalled(); - - // Disabling tailscale can fail with a toast. - h.toastAdd.mockClear(); - bridge.setTailscaleServeEnabled.mockRejectedValueOnce(new Error("serve down")); - const disableButtons = findControls("button", "Restart and disable"); - invoke(disableButtons.at(-1)!, "onClick"); - await flush(); - expect(h.toastAdd).toHaveBeenCalledWith( - expect.objectContaining({ title: "Could not disable Tailscale HTTPS" }), - ); - - // Tailscale setup dialog confirm (valid default port). - bridge.setTailscaleServeEnabled.mockClear(); - invoke(control("button", "Enable"), "onClick"); - await flush(); - expect(bridge.setTailscaleServeEnabled).toHaveBeenCalledWith({ enabled: true, port: 443 }); - h.toastAdd.mockClear(); - bridge.setTailscaleServeEnabled.mockRejectedValueOnce(new Error("setup failed")); - invoke(control("button", "Enable"), "onClick"); - await flush(); - expect(h.toastAdd).toHaveBeenCalledWith( - expect.objectContaining({ title: "Could not set up Tailscale HTTPS" }), - ); - - // The tailscale switch stages setup/disable flows. - invoke(control("switch", "Enable Tailscale HTTPS"), "onCheckedChange", true); - invoke(control("switch", "Enable Tailscale HTTPS"), "onCheckedChange", false); - - // Tailscale port input. - invoke(control("input", "number"), "onChange", { target: { value: "8443" } }); // Dialog open-change plumbing. for (const dialog of findControls("alert-dialog", "false")) { @@ -2872,8 +2834,6 @@ describe("Remote Servers tabs", () => { mode: "local-only", endpointUrl: null, advertisedHost: null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, }, advertisedEndpoints: [], }; @@ -2883,6 +2843,75 @@ describe("Remote Servers tabs", () => { expect(bridge.applyServerExposure).not.toHaveBeenCalled(); }); + it("honours a stored Tailscale HTTPS default before the LAN fallback", () => { + stubDesktopWindow(); + h.networkAccessQuery.data = { + serverExposureState: { + mode: "network-accessible", + endpointUrl: "http://192.168.1.20:5133", + advertisedHost: "192.168.1.20", + }, + advertisedEndpoints: [ + endpoint({ + id: "desktop-lan:1", + label: "LAN", + httpBaseUrl: "http://192.168.1.20:5133", + isDefault: true, + }), + endpoint({ + id: "tailscale-magicdns:1", + label: "Tailscale HTTPS", + httpBaseUrl: "https://machine.tailnet.ts.net", + reachability: "private-network", + hostedHttpsApp: "compatible", + }), + ], + }; + h.uiState.defaultAdvertisedEndpointKey = "tailscale:magicdns:https"; + + const markup = render(); + + const badge = markup.indexOf(">Default"); + expect(badge).toBeGreaterThan(markup.indexOf("Tailscale HTTPS")); + expect(markup.indexOf(">Default", badge + 1)).toBe(-1); + }); + + it("hides an unanswered Tailscale name an older desktop still reports", () => { + stubDesktopWindow(); + h.networkAccessQuery.data = { + serverExposureState: { mode: "local-only", endpointUrl: null, advertisedHost: null }, + advertisedEndpoints: [ + endpoint({ + id: "tailscale-magicdns:1", + label: "Tailscale HTTPS", + httpBaseUrl: "https://machine.tailnet.ts.net", + status: "unavailable", + reachability: "private-network", + hostedHttpsApp: "requires-configuration", + }), + ], + }; + + const markup = render(); + + expect(markup).not.toContain("Setup required"); + expect(markup).not.toContain("https://machine.tailnet.ts.net"); + }); + + it("leaves out the This environment section when it has nothing to show", () => { + stubDesktopWindow(); + h.hasCloudConfig = false; + h.networkAccessQuery.data = { + serverExposureState: { mode: "local-only", endpointUrl: null, advertisedHost: null }, + advertisedEndpoints: [], + }; + + expect(render()).not.toContain('data-section-title="This environment"'); + + h.hasCloudConfig = true; + expect(render()).toContain('data-section-title="This environment"'); + }); + it("renders busy states with the endpoint rail expanded and SSH mode active", async () => { stubDesktopWindow(); h.stateOverrides.set(false, true); @@ -2893,8 +2922,6 @@ describe("Remote Servers tabs", () => { mode: "network-accessible", endpointUrl: "https://desktop.example.com", advertisedHost: null, - tailscaleServeEnabled: true, - tailscaleServePort: 443, }, advertisedEndpoints: [ endpoint({ @@ -2949,7 +2976,6 @@ describe("Remote Servers tabs", () => { const markup = render(); // Busy affordances driven by the overridden boolean state. - expect(markup).toContain("Restarting…"); expect(markup).toContain("Adding…"); expect(markup).toContain("Revoking…"); @@ -2959,14 +2985,7 @@ describe("Remote Servers tabs", () => { invoke(control("button", "Set as default"), "onClick"); expect(h.uiState.setDefaultAdvertisedEndpointKey).toHaveBeenCalled(); - // While tailscale updates are running the rail and confirm buttons all - // render their busy labels; invoking them exercises the busy guards. - expect(markup).toContain("Enter a port from 1 to 65535."); - for (const entry of findControls("button", "Restarting…")) { - if (typeof entry.props.onClick === "function") { - invoke(entry, "onClick"); - } - } + // Busy confirm buttons render their busy labels; invoking them exercises the guards. for (const entry of findControls("button", "Applying…")) { if (typeof entry.props.onClick === "function") { invoke(entry, "onClick"); @@ -3487,8 +3506,6 @@ describe("Remote Servers tabs", () => { mode: "network-accessible", endpointUrl: "https://desktop.example.com" as string | null, advertisedHost: null as string | null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, }; h.networkAccessQuery.data = { serverExposureState: exposureState, diff --git a/apps/web/src/components/settings/remote-servers/ShareTab.tsx b/apps/web/src/components/settings/remote-servers/ShareTab.tsx index ce1e3f1874..ffe909160f 100644 --- a/apps/web/src/components/settings/remote-servers/ShareTab.tsx +++ b/apps/web/src/components/settings/remote-servers/ShareTab.tsx @@ -22,10 +22,8 @@ import { useCopyToClipboard } from "../../../hooks/useCopyToClipboard"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../../timestampFormat"; import { resolveRelayClerkTokenOptions } from "../../../cloud/publicConfig"; import { SettingsRow, SettingsSection, useRelativeTimeTick } from "../settingsLayout"; -import { Input } from "../../ui/input"; import { Dialog, - DialogClose, DialogFooter, DialogDescription, DialogHeader, @@ -35,18 +33,8 @@ import { DialogTrigger, } from "../../ui/dialog"; import { ScrollArea } from "../../ui/scroll-area"; -import { - AlertDialog, - AlertDialogClose, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogPopup, - AlertDialogTitle, -} from "../../ui/alert-dialog"; import { QRCodeSvg } from "../../ui/qr-code"; import { Popover, PopoverPopup, PopoverTrigger } from "../../ui/popover"; -import { Spinner } from "../../ui/spinner"; import { Switch } from "../../ui/switch"; import { stackedThreadToast, toastManager } from "../../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../../ui/tooltip"; @@ -82,10 +70,7 @@ import { import { authEnvironment } from "~/state/auth"; import { idleNamedClients } from "~/state/mcpNamedClients"; import { useEnvironmentQuery } from "~/state/query"; -import { - desktopNetworkAccessStateAtom, - refreshDesktopNetworkAccessState, -} from "~/state/desktopNetworkAccess"; +import { desktopNetworkAccessStateAtom } from "~/state/desktopNetworkAccess"; import { usePrimaryEnvironment } from "~/state/environments"; import { relayEnvironmentDiscovery } from "~/state/relay"; import { useAtomCommand } from "../../../state/use-atom-command"; @@ -95,7 +80,6 @@ import { type AccessSectionPresentation, accessRowClassName, ConnectionStatusDot, - DEFAULT_TAILSCALE_SERVE_PORT, EMPTY_ADVERTISED_ENDPOINTS, endpointDefaultPreferenceKey, endpointRowClassName, @@ -739,9 +723,6 @@ type AdvertisedEndpointListRowProps = { isDefault: boolean; presentation?: AccessSectionPresentation; onSetDefault: (endpoint: AdvertisedEndpoint) => void; - onSetupTailscaleServe: (endpoint: AdvertisedEndpoint) => void; - onDisableTailscaleServe: (endpoint: AdvertisedEndpoint) => void; - isUpdatingTailscaleServe: boolean; }; const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ @@ -749,15 +730,8 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ isDefault, presentation = "current", onSetDefault, - onSetupTailscaleServe, - onDisableTailscaleServe, - isUpdatingTailscaleServe, }: AdvertisedEndpointListRowProps) { const isAvailable = endpoint.status === "available"; - const needsTailscaleSetup = isTailscaleHttpsEndpoint(endpoint) && endpoint.status !== "available"; - const canDisableTailscaleServe = - isTailscaleHttpsEndpoint(endpoint) && endpoint.status === "available"; - const shouldShowEndpointUrl = !needsTailscaleSetup; const isEndpointRail = presentation === "endpoint-rail"; return (
@@ -769,14 +743,12 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({

{endpoint.label}

- {shouldShowEndpointUrl ? ( -

- {endpoint.httpBaseUrl} -

- ) : null} +

+ {endpoint.httpBaseUrl} +

{!isAvailable ? ( Setup required @@ -789,27 +761,7 @@ const AdvertisedEndpointListRow = memo(function AdvertisedEndpointListRow({ Default ) : null} - {needsTailscaleSetup ? ( - - ) : null} - {canDisableTailscaleServe ? ( - - ) : null} - {!needsTailscaleSetup && !isDefault ? ( + {!isDefault ? ( @@ -999,9 +951,6 @@ export function ShareTab({ : primarySessionState.data?.authenticated ? (primarySessionState.data.scopes ?? null) : null; - const [desktopServerExposureMutationError, setDesktopServerExposureMutationError] = useState< - string | null - >(null); const [desktopAccessManagementMutationError, setDesktopAccessManagementMutationError] = useState< string | null >(null); @@ -1012,13 +961,6 @@ export function ShareTab({ string | null >(null); const [isRevokingOtherDesktopClients, setIsRevokingOtherDesktopClients] = useState(false); - const [isUpdatingTailscaleServe, setIsUpdatingTailscaleServe] = useState(false); - const [pendingTailscaleServeEndpoint, setPendingTailscaleServeEndpoint] = - useState(null); - const [disableTailscaleServeDialogOpen, setDisableTailscaleServeDialogOpen] = useState(false); - const [tailscaleServePortInput, setTailscaleServePortInput] = useState( - String(DEFAULT_TAILSCALE_SERVE_PORT), - ); const primaryServerConfig = primaryEnvironment?.serverConfig ?? null; const primaryVersionMismatch = resolveServerConfigVersionMismatch(primaryServerConfig); const defaultAdvertisedEndpointKey = useUiStateStore( @@ -1043,8 +985,7 @@ export function ShareTab({ const desktopServerExposureState = desktopNetworkAccess.data?.serverExposureState ?? null; const desktopAdvertisedEndpoints = desktopNetworkAccess.data?.advertisedEndpoints ?? EMPTY_ADVERTISED_ENDPOINTS; - const desktopServerExposureError = - desktopServerExposureMutationError ?? desktopNetworkAccess.error; + const desktopServerExposureError = desktopNetworkAccess.error; const desktopAccessManagementError = desktopAccessManagementMutationError ?? authAccessChanges.error; const isLoadingDesktopAccessManagement = @@ -1072,95 +1013,6 @@ export function ShareTab({ return idleNamedClients(event?.type === "snapshot" ? event.payload : undefined); }, [authAccessChanges.data]); const isLocalBackendNetworkAccessible = desktopServerExposureState?.mode === "network-accessible"; - const trimmedTailscaleServePortInput = tailscaleServePortInput.trim(); - const parsedTailscaleServePort = Number(trimmedTailscaleServePortInput); - const isTailscaleServePortValid = - /^\d+$/u.test(trimmedTailscaleServePortInput) && - Number.isInteger(parsedTailscaleServePort) && - parsedTailscaleServePort >= 1 && - parsedTailscaleServePort <= 65_535; - - const pendingTailscaleServeBaseUrl = useMemo(() => { - if (!pendingTailscaleServeEndpoint) return null; - if (!isTailscaleServePortValid) return pendingTailscaleServeEndpoint.httpBaseUrl; - if (parsedTailscaleServePort === DEFAULT_TAILSCALE_SERVE_PORT) { - return pendingTailscaleServeEndpoint.httpBaseUrl; - } - try { - const url = new URL(pendingTailscaleServeEndpoint.httpBaseUrl); - url.port = String(parsedTailscaleServePort); - return url.toString().replace(/\/$/u, ""); - } catch { - return pendingTailscaleServeEndpoint.httpBaseUrl; - } - }, [isTailscaleServePortValid, parsedTailscaleServePort, pendingTailscaleServeEndpoint]); - - const handleConfirmTailscaleServeSetup = useCallback(async () => { - if (!desktopBridge) return; - if (!isTailscaleServePortValid) return; - setIsUpdatingTailscaleServe(true); - setDesktopServerExposureMutationError(null); - try { - await desktopBridge.setTailscaleServeEnabled({ - enabled: true, - port: parsedTailscaleServePort, - }); - refreshDesktopNetworkAccessState(); - setPendingTailscaleServeEndpoint(null); - } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to configure Tailscale HTTPS."; - setDesktopServerExposureMutationError(message); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not set up Tailscale HTTPS", - description: message, - }), - ); - } finally { - setIsUpdatingTailscaleServe(false); - } - }, [desktopBridge, isTailscaleServePortValid, parsedTailscaleServePort]); - - const handleStartTailscaleServeSetup = useCallback( - (endpoint: AdvertisedEndpoint) => { - setTailscaleServePortInput( - String(desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT), - ); - setPendingTailscaleServeEndpoint(endpoint); - }, - [desktopServerExposureState?.tailscaleServePort], - ); - - const handleConfirmTailscaleServeDisable = useCallback(async () => { - if (!desktopBridge) return; - const port = desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT; - setIsUpdatingTailscaleServe(true); - setDesktopServerExposureMutationError(null); - try { - await desktopBridge.setTailscaleServeEnabled({ enabled: false, port }); - refreshDesktopNetworkAccessState(); - setDisableTailscaleServeDialogOpen(false); - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to disable Tailscale HTTPS."; - setDesktopServerExposureMutationError(message); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not disable Tailscale HTTPS", - description: message, - }), - ); - } finally { - setIsUpdatingTailscaleServe(false); - } - }, [desktopBridge, desktopServerExposureState?.tailscaleServePort]); - - const handleStartTailscaleServeDisable = useCallback((_endpoint: AdvertisedEndpoint) => { - setDisableTailscaleServeDialogOpen(true); - }, []); - const handleRevokeDesktopPairingLink = useCallback( async (id: string) => { setRevokingDesktopPairingLinkId(id); @@ -1237,8 +1089,13 @@ export function ShareTab({ } }, [desktopIdleNamedClients.length, onAccessRevoked]); const visibleDesktopPairingLinks = desktopPairingLinks; + // An older desktop can still report an unanswered MagicDNS name, which BiBCode no longer + // offers to set up; only an address that answers is listed. const tailscaleHttpsEndpoint = useMemo( - () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, + () => + desktopAdvertisedEndpoints.find( + (endpoint) => isTailscaleHttpsEndpoint(endpoint) && endpoint.status === "available", + ) ?? null, [desktopAdvertisedEndpoints], ); const visibleDesktopNetworkAdvertisedEndpoints = useMemo( @@ -1255,19 +1112,10 @@ export function ShareTab({ : visibleDesktopNetworkAdvertisedEndpoints, [tailscaleHttpsEndpoint, visibleDesktopNetworkAdvertisedEndpoints], ); - const defaultDesktopNetworkAdvertisedEndpoint = useMemo( - () => - selectPairingEndpoint(visibleDesktopNetworkAdvertisedEndpoints, defaultAdvertisedEndpointKey), - [defaultAdvertisedEndpointKey, visibleDesktopNetworkAdvertisedEndpoints], - ); + // One selection over every listed address, so a stored preference wins over any fallback. const defaultDesktopAdvertisedEndpoint = useMemo( - () => - defaultDesktopNetworkAdvertisedEndpoint ?? - selectPairingEndpoint( - tailscaleHttpsEndpoint ? [tailscaleHttpsEndpoint] : [], - defaultAdvertisedEndpointKey, - ), - [defaultAdvertisedEndpointKey, defaultDesktopNetworkAdvertisedEndpoint, tailscaleHttpsEndpoint], + () => selectPairingEndpoint(visibleDesktopAdvertisedEndpoints, defaultAdvertisedEndpointKey), + [defaultAdvertisedEndpointKey, visibleDesktopAdvertisedEndpoints], ); const defaultDesktopAdvertisedEndpointKey = defaultDesktopAdvertisedEndpoint ? endpointDefaultPreferenceKey(defaultDesktopAdvertisedEndpoint) @@ -1278,8 +1126,15 @@ export function ShareTab({ }, [setDefaultAdvertisedEndpointKey], ); + // UI.md: no heading over an empty card. The section has content when it lists an address, + // shows an error or version drift, or offers BiBCode Connect. + const hasThisEnvironmentContent = + primaryVersionMismatch !== null || + hasCloudPublicConfig() || + (desktopBridge !== undefined && + (visibleDesktopAdvertisedEndpoints.length > 0 || Boolean(desktopServerExposureError))); const renderEndpointRows = (presentation: AccessSectionPresentation) => - visibleDesktopNetworkAdvertisedEndpoints.map((endpoint) => { + visibleDesktopAdvertisedEndpoints.map((endpoint) => { const endpointKey = endpointDefaultPreferenceKey(endpoint); return ( ); }); - const renderTailscaleRow = () => ( - {desktopServerExposureError} - ) : null - } - control={ - tailscaleHttpsEndpoint ? ( - { - if (checked) { - handleStartTailscaleServeSetup(tailscaleHttpsEndpoint); - return; - } - handleStartTailscaleServeDisable(tailscaleHttpsEndpoint); - }} - aria-label="Enable Tailscale HTTPS" - /> - ) : null - } - /> - ); const renderAuthorizedClients = (presentation: AccessSectionPresentation) => ( <> {desktopAccessManagementError ? ( @@ -1355,30 +1174,40 @@ export function ShareTab({ <> {canManageLocalBackend ? ( <> - - {primaryVersionMismatch ? ( - - - Client {primaryVersionMismatch.clientVersion}, server{" "} - {primaryVersionMismatch.serverVersion}. Sync them if RPC calls or reconnects - fail. - - } - /> - ) : null} - {desktopBridge ? ( - <> - {renderEndpointRows("endpoint-rail")} - {renderTailscaleRow()} + {hasThisEnvironmentContent ? ( + + {primaryVersionMismatch ? ( + + + Client {primaryVersionMismatch.clientVersion}, server{" "} + {primaryVersionMismatch.serverVersion}. Sync them if RPC calls or reconnects + fail. + + } + /> + ) : null} + {desktopBridge ? ( + <> + {renderEndpointRows("endpoint-rail")} + {desktopServerExposureError ? ( + {desktopServerExposureError} + } + /> + ) : null} + + + ) : ( - - ) : ( - - )} - + )} + + ) : null} - { - if (isUpdatingTailscaleServe) return; - setDisableTailscaleServeDialogOpen(open); - }} - > - - - Disable Tailscale HTTPS? - - BiBCode will restart the local backend without Tailscale Serve. - - - - } - > - Cancel - - - - - - { - if (isUpdatingTailscaleServe) return; - if (!open) setPendingTailscaleServeEndpoint(null); - }} - > - - - Set up Tailscale HTTPS? - - BiBCode will restart the local backend with Tailscale Serve enabled and ask - Tailscale to proxy HTTPS traffic to this backend. - - - - - {!isTailscaleServePortValid ? ( -

Enter a port from 1 to 65535.

- ) : null} -
-

HTTPS endpoint

-

- {pendingTailscaleServeBaseUrl ?? "Pending MagicDNS endpoint"} -

-
-
- - } - > - Cancel - - - -
-
) : ( diff --git a/apps/web/src/components/settings/remote-servers/ShareThisHostTab.test.tsx b/apps/web/src/components/settings/remote-servers/ShareThisHostTab.test.tsx index 368e216d40..6b08e1108d 100644 --- a/apps/web/src/components/settings/remote-servers/ShareThisHostTab.test.tsx +++ b/apps/web/src/components/settings/remote-servers/ShareThisHostTab.test.tsx @@ -26,8 +26,6 @@ const h = vi.hoisted(() => ({ mode: "local-only" as "local-only" | "network-accessible", endpointUrl: null as string | null, advertisedHost: null as string | null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, }, advertisedEndpoints: [] as AdvertisedEndpoint[], }, @@ -146,8 +144,6 @@ const localState = { mode: "local-only" as const, endpointUrl: null, advertisedHost: null, - tailscaleServeEnabled: false, - tailscaleServePort: 443, }; const wideState = { ...localState, @@ -308,7 +304,33 @@ describe("ShareThisHostTab", () => { expect(container.textContent).toContain("Resume legacy remote access"); expect(apply).not.toHaveBeenCalled(); await click("Resume legacy remote access"); - expect(apply).toHaveBeenCalledWith("network-accessible"); + expect(apply).toHaveBeenCalledWith("network-accessible", { allowElevationPrompt: true }); + }); + + it("explains a refused firewall change when legacy resume fails", async () => { + Object.assign(h.networkQuery.data.serverExposureState, { + ...localState, + configuredMode: "network-accessible", + }); + h.getShareState.mockResolvedValue({ + desiredExposure: "loopback", + offHostGrantCount: 0, + legacyGrantCount: 1, + }); + installBridge( + vi.fn(() => + Promise.reject( + "Could not apply server exposure network-accessible: open remote-access firewall rule: firewall-elevation-required. Restored local-only safeguards.", + ), + ), + ); + await renderTab(); + + await click("Resume legacy remote access"); + expect(container.textContent).toContain( + "Windows needs administrator permission to open the firewall for BiBCode", + ); + expect(container.textContent).not.toContain("firewall-elevation-required"); }); it("keeps waiting when legacy exposure resume exceeds the HTTP deadline", async () => { @@ -397,6 +419,58 @@ describe("ShareThisHostTab", () => { expect(container.textContent).toContain("http://192.168.8.194:3773/pair"); }); + it("lists local-only private addresses and widens before minting for the chosen one", async () => { + const localOnly = (host: string, isDefault: boolean): AdvertisedEndpoint => ({ + ...nativePrivateDefaultObservation, + id: `desktop-network:${host}:3773`, + httpBaseUrl: `http://${host}:3773/`, + wsBaseUrl: `ws://${host}:3773/`, + unavailableReason: "local-only", + isDefault, + }); + h.networkQuery.data.advertisedEndpoints = [ + localOnly("192.168.0.10", true), + localOnly("10.20.0.5", false), + ]; + const order: string[] = []; + installBridge( + vi.fn(async () => { + order.push("widen"); + return wideState; + }), + ); + h.createOffer.mockImplementation(async (input) => { + order.push(`mint ${input.endpoint}`); + return { + id: "offer-1", + code: "c0de", + reach: input.reach, + endpoint: input.endpoint, + name: input.name, + expiresAt: DateTime.makeUnsafe("2026-08-27T01:00:00.000Z"), + }; + }); + await renderTab(); + + const select = container.querySelector("select")!; + expect(Array.from(select.options, (option) => option.textContent)).toEqual([ + "Automatic (LAN)", + "192.168.0.10 (enables remote access)", + "10.20.0.5 (enables remote access)", + ]); + await act(async () => { + select.value = "desktop-network:10.20.0.5:3773"; + select.dispatchEvent(new window.Event("change", { bubbles: true })); + }); + expect(container.textContent).toContain( + "BiBCode will enable remote access, then use this address.", + ); + expect(container.textContent).toContain("Running turns on this machine will stop"); + await click("Generate pairing offer"); + expect(order).toEqual(["widen", "mint http://10.20.0.5:3773/"]); + expect(container.textContent).toContain("http://10.20.0.5:3773/pair"); + }); + it("fails closed when native exposure discovers only a public address", async () => { h.networkQuery.data.advertisedEndpoints = [ { @@ -476,7 +550,7 @@ describe("ShareThisHostTab", () => { expect(container.textContent).toContain("Running turns on this machine will stop"); await click("Generate pairing offer"); expect(order).toEqual(["widen", "mint"]); - expect(apply).toHaveBeenCalledWith("network-accessible"); + expect(apply).toHaveBeenCalledWith("network-accessible", { allowElevationPrompt: true }); expect(container.textContent).toContain("bibcode://pair?code=c0de"); expect(container.textContent).toContain("for networks you trust"); expect( @@ -492,6 +566,56 @@ describe("ShareThisHostTab", () => { expect(container.textContent).toContain("bind failed"); }); + it("notes on Windows that a network marked Public may still block other devices", async () => { + const note = "On a network Windows marks Public, other devices may still be blocked."; + installBridge(); + await renderTab(); + expect(container.textContent).not.toContain(note); + + await act(async () => root.unmount()); + root = createRoot(container); + Object.defineProperty(navigator, "platform", { configurable: true, value: "Win32" }); + await renderTab(); + expect(container.textContent).toContain( + "The firewall rule BiBCode adds covers networks Windows marks Private or Domain.", + ); + expect(container.textContent).toContain(note); + + await selectIntent("this-computer"); + expect(container.textContent).not.toContain(note); + }); + + it("asks for administrator permission only on Generate and Retry, and explains a refusal", async () => { + const apply = vi.fn< + ( + desired: DesktopServerExposureMode, + options?: { readonly allowElevationPrompt?: boolean }, + ) => Promise + >(() => + // Tauri rejects with the desktop's plain string, not an Error. + Promise.reject( + "Could not apply server exposure network-accessible: open remote-access firewall rule: firewall-elevation-required. Restored local-only safeguards.", + ), + ); + installBridge(apply); + await renderTab(); + + await click("Generate pairing offer"); + expect(apply).toHaveBeenLastCalledWith("network-accessible", { allowElevationPrompt: true }); + expect(h.createOffer).not.toHaveBeenCalled(); + expect(container.textContent).toContain( + "Windows needs administrator permission to open the firewall for BiBCode, so remote access was not turned on. This computer is still local-only.", + ); + expect(container.textContent).not.toContain("Could not enable remote access."); + + apply.mockResolvedValueOnce(wideState); + await click("Retry"); + expect(apply).toHaveBeenCalledTimes(2); + expect(apply).toHaveBeenLastCalledWith("network-accessible", { allowElevationPrompt: true }); + expect(container.textContent).toContain("bibcode://pair?code=c0de"); + expect(container.textContent).not.toContain("Windows needs administrator permission"); + }); + it("reports completed local-only restoration when minting fails after widening", async () => { const apply = installBridge( vi.fn(async (desired: "local-only" | "network-accessible") => @@ -507,7 +631,11 @@ describe("ShareThisHostTab", () => { await renderTab(); await click("Generate pairing offer"); - expect(apply.mock.calls).toEqual([["network-accessible"], ["local-only"]]); + // The person's widen may prompt; the automatic narrowing after it never does. + expect(apply.mock.calls).toEqual([ + ["network-accessible", { allowElevationPrompt: true }], + ["local-only"], + ]); expect(container.textContent).toContain( "The offer was not created. Remote access is confirmed local-only.", ); @@ -552,7 +680,9 @@ describe("ShareThisHostTab", () => { await renderTab(); await click("Generate pairing offer"); - expect(apply).toHaveBeenCalledExactlyOnceWith("network-accessible"); + expect(apply).toHaveBeenCalledExactlyOnceWith("network-accessible", { + allowElevationPrompt: true, + }); expect(container.textContent).toContain( "The offer was not created. Remote access remains enabled because another live access reason still requires it.", ); @@ -581,7 +711,9 @@ describe("ShareThisHostTab", () => { // The authoritative share state still reports a live off-host reason, so // the reconciling cleanup keeps the host wide with honest copy. - expect(apply).toHaveBeenCalledExactlyOnceWith("network-accessible"); + expect(apply).toHaveBeenCalledExactlyOnceWith("network-accessible", { + allowElevationPrompt: true, + }); expect(container.textContent).toContain( "The offer was not created. Remote access remains enabled because another live access reason still requires it.", ); @@ -610,7 +742,11 @@ describe("ShareThisHostTab", () => { expect(h.cancelOffer).toHaveBeenCalledTimes(3); // The authority reports no live sharing reason, so the reconciling // cleanup narrows instead of leaving the host wide until restart. - expect(apply.mock.calls).toEqual([["network-accessible"], ["local-only"]]); + // The person's widen may prompt; the automatic narrowing after it never does. + expect(apply.mock.calls).toEqual([ + ["network-accessible", { allowElevationPrompt: true }], + ["local-only"], + ]); expect(container.textContent).toContain( "The offer was not created. Remote access is confirmed local-only.", ); diff --git a/apps/web/src/components/settings/remote-servers/ShareThisHostTab.tsx b/apps/web/src/components/settings/remote-servers/ShareThisHostTab.tsx index c0be4cb030..007cf2c281 100644 --- a/apps/web/src/components/settings/remote-servers/ShareThisHostTab.tsx +++ b/apps/web/src/components/settings/remote-servers/ShareThisHostTab.tsx @@ -34,7 +34,7 @@ import { reconcileShareExposureOnce, withShareExposureBridgeTimeout, } from "~/state/shareExposureReconciler"; -import { randomUUID } from "~/lib/utils"; +import { isWindowsPlatform, randomUUID } from "~/lib/utils"; import { Button } from "../../ui/button"; import { Input } from "../../ui/input"; @@ -43,6 +43,7 @@ import { Radio, RadioGroup } from "../../ui/radio-group"; import { SettingsRow, SettingsSection } from "../settingsLayout"; import { shareClassForPairingEndpoint } from "./endpointClass"; import { + describeWidenFailure, type GeneratedShareOffer, type GenerateShareOfferFailure, generateShareOffer, @@ -177,11 +178,12 @@ export function ShareThisHostTab(): ReactElement { setIsResumingLegacy(true); setLegacyResumeError(null); try { - await desktopBridge.applyServerExposure("network-accessible"); + // The person asked to resume, so Windows may ask them for permission. + await desktopBridge.applyServerExposure("network-accessible", { allowElevationPrompt: true }); refreshDesktopNetworkAccessState(); await refreshShareState(); } catch (error) { - setLegacyResumeError(error instanceof Error ? error.message : String(error)); + setLegacyResumeError(describeWidenFailure(error).message); } finally { setIsResumingLegacy(false); } @@ -207,7 +209,8 @@ export function ShareThisHostTab(): ReactElement { applyServerExposure: !canManageNativeExposure || desktopBridge === undefined ? null - : (desired) => desktopBridge.applyServerExposure(desired), + : // The person pressed Generate or Retry, so Windows may ask them for permission. + (desired) => desktopBridge.applyServerExposure(desired, { allowElevationPrompt: true }), mintOffer: async ({ idempotencyKey, ...input }) => { const minted = await createServerPairingOffer(input, idempotencyKey); return { @@ -407,7 +410,29 @@ export function ShareThisHostTab(): ReactElement { stop.

) : null} - {sectionError ?

{sectionError}

: null} + {intent === "another-device" && + canManageNativeExposure && + isWindowsPlatform(navigator.platform) ? ( +

+ The firewall rule BiBCode adds covers networks Windows marks Private or Domain. On a + network Windows marks Public, other devices may still be blocked. +

+ ) : null} + {sectionError ? ( +
+

{sectionError}

+ {failure?.kind === "widen-failed" ? ( + + ) : null} +
+ ) : null}