diff --git a/bun.lock b/bun.lock index b751f4b7bd3..844f336e707 100644 --- a/bun.lock +++ b/bun.lock @@ -730,6 +730,7 @@ "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", "vite": "catalog:", + "vite-plugin-solid": "catalog:", }, }, "packages/simulation": { @@ -6255,6 +6256,8 @@ "@opencode-ai/session-ui/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + "@opencode-ai/session-ui/vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="], + "@opencode-ai/stats-app/vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], "@opencode-ai/storybook/@tailwindcss/vite": ["@tailwindcss/vite@4.1.11", "", { "dependencies": { "@tailwindcss/node": "4.1.11", "@tailwindcss/oxide": "4.1.11", "tailwindcss": "4.1.11" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-RHYhrR3hku0MJFRV+fN2gNbDNEh3dwKvY8XJvTxCSXeMOsCRSr+uKvDWQcbizrHgjML6ZmTE5OwMrl5wKcujCw=="], diff --git a/packages/app/e2e/performance/markdown/README.md b/packages/app/e2e/performance/markdown/README.md new file mode 100644 index 00000000000..58fd184feb4 --- /dev/null +++ b/packages/app/e2e/performance/markdown/README.md @@ -0,0 +1,44 @@ +# Timeline Preload Lifetime + +This manual benchmark uses the production app, restored session tabs, the real +`MessageTimeline` preload, and the real Markdown worker. Only API data and result +delivery timing are fixture-owned. It does not connect to a running OpenCode +service or send prompts. + +From `packages/app`, set absolute `MARKDOWN_APP_BUILD_DIR` and +`MARKDOWN_RESULTS_DIR` artifact paths, then run: + +```sh +bun run build +# Copy dist into MARKDOWN_APP_BUILD_DIR before editing production source. +bun --bun x playwright test --config e2e/performance/markdown/playwright.config.ts --repeat-each 20 +``` + +Each isolated sample restores two sessions with one user message and one completed +assistant text part each. The cold target has a realistic recovery review with +either two TypeScript fences (typical) or 36 fences (large). The source has a short +completed answer. Target data is prefetched before selection, but its Markdown is +not parsed until the target is selected. + +The app's service-worker generator reads `dist`, so use the normal build output +and freeze a copy, rather than overriding Vite's build output directory. + +The real worker result is held after admission. The test selects the original +session again and releases the held result only after the abandoned timeline row +detaches and the selected answer reports production Markdown readiness. This +exercises both the timeline preload and the nested Markdown consumer, including +the case where either one would otherwise keep a shared parse alive. + +Destination readiness and post-disposal result settlement are separate metrics. +The latter is a MessageChannel task after the result's promise microtasks drain. +The DOMParser probe counts actual DOMPurify input containing the abandoned answer +after disposal, in characters. CDP reports renderer task/script time and JS heap. +These are not worker CPU, Electron process RAM, or ungated tab-switch measurements. +In particular, this gate releases the result after destination readiness and must +not be used to claim a destination-readiness gain from skipping sanitization. + +`MARKDOWN_ASSERT_DISPOSAL=1` enables the no-obsolete-sanitization assertion. Use +`MARKDOWN_RETAINED=1` only in separate post-GC runs. The repository trace collector +is available through `OPENCODE_PERFORMANCE_TRACE_DIR`, and `MARKDOWN_SCREENSHOT` +captures final output after timing. Run serially, preserve frozen builds, and keep +all results outside Git. diff --git a/packages/app/e2e/performance/markdown/lifetime.bench.ts b/packages/app/e2e/performance/markdown/lifetime.bench.ts new file mode 100644 index 00000000000..8e32c891c5e --- /dev/null +++ b/packages/app/e2e/performance/markdown/lifetime.bench.ts @@ -0,0 +1,125 @@ +import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import { benchmark, expect } from "../benchmark" +import { mockOpenCodeServer } from "../../utils/mock-server" +import { fixture } from "../timeline/session-timeline-stress.fixture" +import { installStressSessionTabs, installTimelineSettings, stressSessionHref } from "../timeline/timeline-test-helpers" +import { completedAnswer } from "../../../../session-ui/performance/markdown-lifetime/answer" +import { installMarkdownGate } from "./probe" + +for (const size of ["typical", "large"]) { + benchmark(`timeline preload disposal: ${size}`, async ({ page, report }) => { + const answer = completedAnswer(size === "typical" ? 2 : 36) + const errors: string[] = [] + page.on("pageerror", (error) => errors.push(error.message)) + const messages: Record = Object.fromEntries( + [fixture.sourceID, fixture.targetID].map((id) => [ + id, + [ + { + id: `msg_1_${id}_user`, + type: "user", + time: { created: 1700000000000 }, + text: "Review the recovery boundary.", + }, + { + id: `msg_2_${id}_assistant`, + type: "assistant", + time: { created: 1700000001000, completed: 1700000008000 }, + model: { id: "claude-opus-4-6", providerID: "opencode" }, + agent: "build", + cost: 0.01, + tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }, + finish: "stop", + content: [ + { + type: "text", + text: + id === fixture.targetID + ? answer + : "## Current destination\n\nThe selected session is ready.\n\n```typescript\nconst current = { ready: true }\n```", + }, + ], + }, + ] satisfies SessionMessageInfo[], + ]), + ) + await mockOpenCodeServer(page, { + sessions: fixture.sessions.filter((session) => session.id !== fixture.childID), + provider: fixture.provider, + directory: fixture.directory, + project: fixture.project, + pageMessages: (id) => ({ items: messages[id] ?? [] }), + }) + await installTimelineSettings(page) + await installStressSessionTabs(page) + const targetPart = `msg_2_${fixture.targetID}_assistant:text:0` + const sourcePart = `msg_2_${fixture.sourceID}_assistant:text:0` + await installMarkdownGate(page, { answer, targetPart, sourcePart, href: stressSessionHref(fixture.sourceID) }) + const prefetched = page.waitForResponse((response) => + new URL(response.url()).pathname.endsWith(`/session/${fixture.targetID}/message`), + ) + await page.goto(stressSessionHref(fixture.sourceID)) + await prefetched + const source = page.locator(`[data-timeline-part-id="${sourcePart}"] [data-component="markdown"]`) + await expect(source).toHaveAttribute("data-markdown-ready", "") + await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.targetID)}"]`).click() + await page.waitForFunction(() => Reflect.get(window, "markdownGate").held) + await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toBeAttached() + const cdp = await page.context().newCDPSession(page) + await cdp.send("Performance.enable") + const before = await cdp.send("Performance.getMetrics") + await page.evaluate(() => Reflect.get(window, "markdownGate").arm()) + await page.locator(`[data-slot="titlebar-tabs"] a[href="${stressSessionHref(fixture.sourceID)}"]`).click() + await expect(source).toHaveAttribute("data-markdown-ready", "") + await expect(source.getByRole("heading", { name: "Current destination" })).toBeVisible() + await expect(page.locator(`[data-timeline-part-id="${targetPart}"]`)).toHaveCount(0) + await page.waitForFunction(() => Reflect.get(window, "markdownGate").settled > 0) + const after = await cdp.send("Performance.getMetrics") + const stats = await page.evaluate(() => { + const value = Reflect.get(window, "markdownGate") + return { + admitted: value.admitted, + responses: value.responses, + started: value.started, + ready: value.ready, + released: value.released, + settled: value.settled, + sanitizeCalls: value.sanitizeCalls, + sanitizeChars: value.sanitizeChars, + } + }) + expect(stats.admitted).toBe(1) + expect(stats.responses).toBe(1) + expect(stats.ready).toBeGreaterThan(stats.started) + expect(stats.settled).toBeGreaterThan(stats.released) + expect(errors).toEqual([]) + if (process.env.MARKDOWN_ASSERT_DISPOSAL === "1") expect(stats.sanitizeCalls).toBe(0) + const value = (data: typeof after, name: string) => data.metrics.find((item) => item.name === name)!.value + const retained = process.env.MARKDOWN_RETAINED === "1" + if (retained) await cdp.send("HeapProfiler.collectGarbage") + report( + { + ...stats, + destinationReadyMs: stats.ready - stats.started, + releasedSettledMs: stats.settled - stats.released, + taskMs: (value(after, "TaskDuration") - value(before, "TaskDuration")) * 1000, + scriptMs: (value(after, "ScriptDuration") - value(before, "ScriptDuration")) * 1000, + usedHeapBytes: (await cdp.send("Runtime.getHeapUsage")).usedSize, + }, + { + size, + retained, + answerBytes: Buffer.byteLength(answer), + messagesPerSession: 2, + partsPerAnswer: 1, + fences: size === "typical" ? 2 : 36, + browser: page.context().browser()!.version(), + transport: "playwright-route", + build: process.env.MARKDOWN_APP_BUILD_DIR, + }, + ) + if (process.env.MARKDOWN_SCREENSHOT) + await page.screenshot({ path: `${process.env.MARKDOWN_SCREENSHOT}/timeline-${size}.png` }) + await cdp.detach() + }) +} diff --git a/packages/app/e2e/performance/markdown/playwright.config.ts b/packages/app/e2e/performance/markdown/playwright.config.ts new file mode 100644 index 00000000000..6bd7788bf8d --- /dev/null +++ b/packages/app/e2e/performance/markdown/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "@playwright/test" +import { fileURLToPath } from "node:url" + +process.env.PLAYWRIGHT_PORT = "6199" +process.env.PLAYWRIGHT_SERVER_PORT = "6199" +process.env.PLAYWRIGHT_SERVER_HOST = "127.0.0.1" + +export default defineConfig({ + testDir: ".", + testMatch: "*.bench.ts", + outputDir: process.env.MARKDOWN_RESULTS_DIR, + workers: 1, + retries: 0, + timeout: 60_000, + expect: { timeout: 15_000 }, + reporter: [["line"]], + use: { baseURL: "http://127.0.0.1:6199", viewport: { width: 1280, height: 900 }, serviceWorkers: "block" }, + webServer: { + cwd: fileURLToPath(new URL("../../..", import.meta.url)), + command: `bun run serve -- --host 127.0.0.1 --port 6199 --strictPort --outDir "${process.env.MARKDOWN_APP_BUILD_DIR}"`, + url: "http://127.0.0.1:6199", + reuseExistingServer: false, + }, +}) diff --git a/packages/app/e2e/performance/markdown/probe.ts b/packages/app/e2e/performance/markdown/probe.ts new file mode 100644 index 00000000000..8bd250e7add --- /dev/null +++ b/packages/app/e2e/performance/markdown/probe.ts @@ -0,0 +1,93 @@ +import type { Page } from "@playwright/test" +import type { + MarkdownWorkerRequest, + MarkdownWorkerResponse, +} from "../../../../session-ui/src/components/markdown-worker-protocol" + +export async function installMarkdownGate( + page: Page, + input: { answer: string; sourcePart: string; targetPart: string; href: string }, +) { + await page.addInitScript(({ answer, sourcePart, targetPart, href }) => { + const stats = { + admitted: 0, + responses: 0, + held: false, + started: 0, + ready: 0, + released: 0, + settled: 0, + sanitizeCalls: 0, + sanitizeChars: 0, + arm: () => { + armed = true + }, + } + let armed = false + let id: number | undefined + let release: (() => void) | undefined + const descriptor = Object.getOwnPropertyDescriptor(Worker.prototype, "onmessage")! + const post = Worker.prototype.postMessage + Object.defineProperty(Worker.prototype, "onmessage", { + configurable: true, + get: descriptor.get, + set(callback: (event: MessageEvent) => void) { + descriptor.set!.call(this, (event: MessageEvent) => { + if (event.data.type === "parse" && event.data.id === id) { + stats.responses++ + stats.held = true + release = () => callback.call(this, event) + return + } + callback.call(this, event) + }) + }, + }) + Worker.prototype.postMessage = function (request: MarkdownWorkerRequest) { + if (request.type === "parse" && request.text === answer) { + id = request.id + stats.admitted++ + } + post.call(this, request) + } + const parse = DOMParser.prototype.parseFromString + DOMParser.prototype.parseFromString = function (text, type) { + if (stats.released && String(text).includes("Recovery implementation review")) { + stats.sanitizeCalls++ + stats.sanitizeChars += String(text).length + } + return parse.call(this, text, type) + } + document.addEventListener( + "mousedown", + (event) => { + if (!armed || stats.started) return + const target = event.target instanceof Element ? event.target.closest("a") : undefined + if (target?.getAttribute("href") !== href) return + stats.started = performance.now() + }, + true, + ) + // The app can retain the outgoing view until the destination is ready. Release + // only after its actual row detaches, rather than assuming click means dispose. + new MutationObserver(() => { + if (!stats.started || stats.released) return + const current = document.querySelector(`[data-timeline-part-id="${sourcePart}"] [data-markdown-ready]`) + if (!current) return + stats.ready ||= performance.now() + if (document.querySelector(`[data-timeline-part-id="${targetPart}"]`)) return + stats.released = performance.now() + performance.mark("markdown-timeline-disposed") + release!() + release = undefined + const channel = new MessageChannel() + channel.port1.onmessage = () => { + stats.settled = performance.now() + channel.port1.close() + channel.port2.close() + } + channel.port2.postMessage(null) + }).observe(document, { childList: true, subtree: true, attributes: true }) + Object.defineProperty(window, "markdownGate", { value: stats }) + }, input) +} diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 720580f2040..f0a56539d3c 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, For, on, Show, type Accessor, type JSX } from "solid-js" +import { createEffect, createMemo, createSignal, For, on, onCleanup, Show, type Accessor, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createAnimatedPresence } from "@/runtime/animated-presence" import type { SessionUserActions } from "@opencode-ai/session-ui/actions" @@ -342,8 +342,11 @@ export function MessageTimeline(props: MessageTimelineProps) { if (message?.type === "assistant" && message.time.completed !== undefined) { const content = Timeline.resolveContent(message, tail.group.ref.partID) // Start the required worker job while the rest of the selected view is constructed. - if (content?.type === "text" && content.text.trim()) - void preloadMarkdown(content.text, tail.group.ref.partID).catch(() => undefined) + if (content?.type === "text" && content.text.trim()) { + const preload = new AbortController() + onCleanup(() => preload.abort()) + void preloadMarkdown(content.text, tail.group.ref.partID, preload.signal).catch(() => undefined) + } } } return ( diff --git a/packages/session-ui/component-tests/markdown.fixture.tsx b/packages/session-ui/component-tests/markdown.fixture.tsx index 3a1753a5eae..0c1a4c46fd1 100644 --- a/packages/session-ui/component-tests/markdown.fixture.tsx +++ b/packages/session-ui/component-tests/markdown.fixture.tsx @@ -13,6 +13,8 @@ export { touchCachedMarkdown, } from "../src/components/markdown-cache" export { renderMermaidSvg } from "../src/components/markdown-mermaid" +export { MarkdownWorkerDisposedError } from "../src/components/markdown-worker" +export { preloadMarkdown } export async function mountMarkdown(options: { text: string diff --git a/packages/session-ui/component-tests/markdown.spec.ts b/packages/session-ui/component-tests/markdown.spec.ts index 21b3d39db2f..3641f68fad8 100644 --- a/packages/session-ui/component-tests/markdown.spec.ts +++ b/packages/session-ui/component-tests/markdown.spec.ts @@ -206,6 +206,104 @@ story("shares in-flight Markdown rendering without overwriting a reclaimed cache expect(result.html).toContain("Shared result") }) +story("settles an abandoned parse and permits immediate cache-key reuse", async ({ page }) => { + const result = await page.evaluate(async (fixture) => { + const { getCachedMarkdown, renderCachedMarkdown, MarkdownWorkerDisposedError } = await import(fixture) + const controller = new AbortController() + const raw = "```typescript\nconst abandoned = true\n```" + const pending = renderCachedMarkdown({ raw, src: raw }, "released", controller.signal).catch( + (error: unknown) => error instanceof MarkdownWorkerDisposedError, + ) + controller.abort() + const rejected = await pending + const empty = getCachedMarkdown("released") === undefined + const replacement = "```typescript\nconst replacement = true\n```" + const rendered = await renderCachedMarkdown({ raw: replacement, src: replacement }, "released") + return { rejected, empty, cached: getCachedMarkdown("released") === rendered, html: rendered.html } + }, fixture) + expect(result).toMatchObject({ rejected: true, empty: true, cached: true }) + expect(result.html).toContain("replacement") + expect(result.html).not.toContain("abandoned") +}) + +for (const owned of [true, false]) { + story( + `preserves a shared parse for ${owned ? "another mounted consumer" : "an explicit preload"}`, + async ({ page }) => { + const result = await page.evaluate( + async ({ fixture, owned }) => { + const { getCachedMarkdown, renderCachedMarkdown, MarkdownWorkerDisposedError } = await import(fixture) + const first = new AbortController() + const second = new AbortController() + const raw = "```typescript\nconst shared = true\n```" + const pending = renderCachedMarkdown({ raw, src: raw }, "shared-lifetime", first.signal).catch( + (error: unknown) => error instanceof MarkdownWorkerDisposedError, + ) + let complete = false + const survivor = renderCachedMarkdown( + { raw, src: raw }, + "shared-lifetime", + owned ? second.signal : undefined, + ).then((value: { html: string }) => { + complete = true + return value + }) + first.abort() + const rejected = await pending + const independent = !complete + const rendered = await survivor + second.abort() + return { + rejected, + independent, + cached: getCachedMarkdown("shared-lifetime") === rendered, + html: rendered.html, + } + }, + { fixture, owned }, + ) + expect(result).toMatchObject({ rejected: true, independent: true, cached: true }) + expect(result.html).toContain("shared") + }, + ) +} + +story("does not admit an already disposed Markdown consumer", async ({ page }) => { + const result = await page.evaluate(async (fixture) => { + const { getCachedMarkdown, renderCachedMarkdown, MarkdownWorkerDisposedError } = await import(fixture) + const controller = new AbortController() + controller.abort() + const raw = "```typescript\nconst ignored = true\n```" + const rejected = await renderCachedMarkdown({ raw, src: raw }, "already-disposed", controller.signal).then( + () => false, + (error: unknown) => error instanceof MarkdownWorkerDisposedError, + ) + return { rejected, empty: getCachedMarkdown("already-disposed") === undefined } + }, fixture) + expect(result).toEqual({ rejected: true, empty: true }) +}) + +story("releases a timeline preload without cancelling a mounted shared consumer", async ({ page }) => { + const result = await page.evaluate(async (fixture) => { + const { getCachedMarkdown, preloadMarkdown, renderCachedMarkdown, MarkdownWorkerDisposedError } = await import( + fixture + ) + const controller = new AbortController() + const raw = "```typescript\nconst preloaded = true\n```" + const preload = preloadMarkdown(raw, "timeline-preload", controller.signal).then( + () => false, + (error: unknown) => error instanceof MarkdownWorkerDisposedError, + ) + const mounted = renderCachedMarkdown({ raw, src: raw }, "timeline-preload:0:full") + controller.abort() + const rejected = await preload + const rendered = await mounted + return { rejected, cached: getCachedMarkdown("timeline-preload:0:full") === rendered, html: rendered.html } + }, fixture) + expect(result).toMatchObject({ rejected: true, cached: true }) + expect(result.html).toContain("preloaded") +}) + story("keeps a reopened cached answer recent under cache pressure", async ({ page }) => { await page.evaluate(async (fixture) => { const { mountMarkdown, touchCachedMarkdown } = await import(fixture) diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 41fe2a8886a..b7b1d0d8b7b 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -57,7 +57,8 @@ "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:", - "vite": "catalog:" + "vite": "catalog:", + "vite-plugin-solid": "catalog:" }, "dependencies": { "@kobalte/core": "catalog:", diff --git a/packages/session-ui/performance/markdown-lifetime/README.md b/packages/session-ui/performance/markdown-lifetime/README.md new file mode 100644 index 00000000000..30d03bf5d4a --- /dev/null +++ b/packages/session-ui/performance/markdown-lifetime/README.md @@ -0,0 +1,50 @@ +# Completed Markdown Job Lifetime + +This manual browser benchmark bundles the production `Markdown` component, +cache, sanitizer, and worker with Vite's production mode. It does not connect to +an OpenCode server or use user data. Run from `packages/session-ui`. + +Set `MARKDOWN_BUILD_DIR` and `MARKDOWN_RESULTS_DIR` to absolute artifact directories: + +```sh +bun --bun x vite build --config performance/markdown-lifetime/vite.config.ts +bun --bun x playwright test --config performance/markdown-lifetime/playwright.config.ts --repeat-each 20 +``` + +The three independent scenarios render one deterministic long completed answer +with 36 TypeScript fences, explanatory prose, links, inline code, and tables: + +- `mounted`: keep the consumer mounted through result delivery. +- `leave`: unmount the last consumer before delivering its parse result and render + a short completed answer in the current destination. +- `shared`: unmount one of two consumers with the same cache key, preserving the + other consumer's result. + +The fixture intercepts only the real worker's message delivery. Admission and +worker-result arrival enable the Continue button. Continue disposes the departing +consumer, then delivers the held result. No parser work is replaced. This makes +the unmount/result ordering deterministic without wall-clock delays. Cold worker +startup and parsing happen before the measured interval; this benchmark measures +main-thread work after the worker has produced a result, not worker CPU savings. + +Readiness is the production component's `data-markdown-ready` state, observed by +a MutationObserver. A MessageChannel task marks completion of the released +result's promise microtasks, including cache postprocessing. CDP reports renderer +task/script duration and renderer JS heap, not total browser or desktop RAM. +The cache-character metric confirms whether the obsolete result was sanitized and +stored. It is a mechanism check, not the performance result by itself. + +The initial frozen baseline called this field `cacheBytes`, although it counted +HTML characters. The runner normalizes that frozen-build field to `cacheChars`; +the renderer probe operation is unchanged. + +Use `MARKDOWN_ASSERT_DISPOSAL=1` to assert that the abandoned result is not cached. +Use `MARKDOWN_RETAINED=1` in separate runs for a post-GC retention check; do not mix +these samples with natural-GC heap measurements. `OPENCODE_PERFORMANCE_TRACE_DIR` +enables the repository's Chrome trace collector. `MARKDOWN_SCREENSHOT` optionally +captures each final scenario after timing. All artifacts belong outside Git. + +Freeze the baseline build before changing production source and reuse the same +fixture, runtime, viewport, and readiness contracts for the candidate. Do not use +machine-dependent latency thresholds. The scenarios test real components, but do +not reproduce full session navigation or establish Electron process-memory gains. diff --git a/packages/session-ui/performance/markdown-lifetime/answer.ts b/packages/session-ui/performance/markdown-lifetime/answer.ts new file mode 100644 index 00000000000..faec42d661a --- /dev/null +++ b/packages/session-ui/performance/markdown-lifetime/answer.ts @@ -0,0 +1,13 @@ +export function completedAnswer(sections: number) { + return `# Recovery implementation review\n\n${Array.from({ length: sections }, (_, index) => { + const service = ["catalog", "billing", "delivery", "inventory", "accounts", "notifications"][index % 6] + return [ + `## ${index + 1}. Validate the ${service} recovery boundary`, + `The ${service} service should publish durable progress before acknowledging a request. Keep the request ID in the transaction so a retry does not create a second operation. The implementation below separates admission from delivery and makes the recovery decision explicit.`, + "Check the existing rows before scheduling work. A disconnected client is not evidence that the operation failed, and the background processor must not delete accepted work when a view closes. Use the stored status for the next attempt, not a process-local flag.", + `\`\`\`typescript\nexport async function recover${index}(db: Database, request: Request) {\n const previous = await db.operations.find(request.id)\n if (previous?.status === "complete") return previous.result\n const operation = previous ?? await db.transaction(async (tx) => {\n const row = await tx.operations.insert({\n id: request.id,\n service: "${service}",\n status: "accepted",\n payload: request.payload,\n })\n await tx.events.publish({ type: "operation.accepted", id: row.id })\n return row\n })\n await schedule(operation.id)\n return { id: operation.id, status: operation.status }\n}\n\`\`\``, + "| Condition | Expected behavior |\n| --- | --- |\n| Duplicate request | Return the first accepted result |\n| Worker restart | Resume the stored operation |\n| Client leaves | Retain accepted work without retaining the view |", + `Run the focused test with \`bun test test/${service}/recovery.test.ts\`. Verify the [transaction contract](https://example.com/transactions) and inspect the operation's final state before expanding the rollout.`, + ].join("\n\n") + }).join("\n\n")}\n\n**Review complete.**` +} diff --git a/packages/session-ui/performance/markdown-lifetime/fixture.tsx b/packages/session-ui/performance/markdown-lifetime/fixture.tsx new file mode 100644 index 00000000000..66e51338519 --- /dev/null +++ b/packages/session-ui/performance/markdown-lifetime/fixture.tsx @@ -0,0 +1,130 @@ +import { createSignal, Show } from "solid-js" +import { render } from "solid-js/web" +import { Markdown } from "../../src/components/markdown" +import { getCachedMarkdown } from "../../src/components/markdown-cache" +import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from "../../src/components/markdown-worker-protocol" +import "@opencode-ai/ui/styles" +import "@opencode-ai/ui/styles/tokens" +import "../../src/components/markdown.css" + +const scenario = new URLSearchParams(location.search).get("scenario") ?? "mounted" +const sections = Array.from({ length: 36 }, (_, index) => { + const service = ["catalog", "billing", "delivery", "inventory", "accounts", "notifications"][index % 6] + return [ + `## ${index + 1}. Validate the ${service} recovery boundary`, + `The ${service} service should publish durable progress before acknowledging a request. Keep the request ID in the transaction so a retry does not create a second operation. The implementation below separates admission from delivery and makes the recovery decision explicit.`, + "Check the existing rows before scheduling work. A disconnected client is not evidence that the operation failed, and the background processor must not delete accepted work when a view closes. Use the stored status for the next attempt, not a process-local flag.", + `\`\`\`typescript\nexport async function recover${index}(db: Database, request: Request) {\n const previous = await db.operations.find(request.id)\n if (previous?.status === "complete") return previous.result\n const operation = previous ?? await db.transaction(async (tx) => {\n const row = await tx.operations.insert({\n id: request.id,\n service: "${service}",\n status: "accepted",\n payload: request.payload,\n })\n await tx.events.publish({ type: "operation.accepted", id: row.id })\n return row\n })\n await schedule(operation.id)\n return { id: operation.id, status: operation.status }\n}\n\`\`\``, + "| Condition | Expected behavior |\n| --- | --- |\n| Duplicate request | Return the first accepted result |\n| Worker restart | Resume the stored operation |\n| Client leaves | Retain accepted work without retaining the view |", + `Run the focused test with \`bun test test/${service}/recovery.test.ts\`. Verify the [transaction contract](https://example.com/transactions) and inspect the operation's final state before expanding the rollout.`, + ].join("\n\n") +}) +const answer = `# Recovery implementation review\n\n${sections.join("\n\n")}\n\n**Review complete.**` +const destination = + "## Current destination\n\nThe new session is ready.\n\n```typescript\nconst current = { ready: true }\n```" +const stats = { + bytes: new TextEncoder().encode(answer).length, + fences: sections.length, + messages: 1, + parts: 1, + requests: 0, + responses: 0, + released: 0, + ready: 0, + settled: 0, + disposed: false, + cacheChars: 0, +} + +// Keep the real worker and parser. Only hold delivery of this answer's result so +// disposal always happens after admission and before main-thread postprocessing. +const descriptor = Object.getOwnPropertyDescriptor(Worker.prototype, "onmessage")! +const post = Worker.prototype.postMessage +let held: (() => void) | undefined +let answerID: number | undefined +Object.defineProperty(Worker.prototype, "onmessage", { + configurable: true, + get: descriptor.get, + set(callback: (event: MessageEvent) => void) { + descriptor.set!.call(this, (event: MessageEvent) => { + if (event.data.type === "parse" && event.data.id === answerID) { + stats.responses++ + held = () => callback.call(this, event) + document.querySelector("#continue")!.disabled = false + return + } + callback.call(this, event) + }) + }, +}) +Worker.prototype.postMessage = function (request: MarkdownWorkerRequest) { + if (request.type === "parse" && request.text === answer) { + answerID = request.id + stats.requests++ + } + post.call(this, request) +} + +const observer = new MutationObserver(() => { + if (!stats.released || stats.ready) return + const target = document.querySelector(scenario === "leave" ? "#destination" : "#survivor") + if (!target?.hasAttribute("data-markdown-ready")) return + stats.ready = performance.now() + document.body.dataset.ready = "true" +}) +observer.observe(document.body, { subtree: true, attributes: true, childList: true }) + +render(() => { + const [admitted, setAdmitted] = createSignal(false) + const [leaving, setLeaving] = createSignal(false) + return ( +
+ + + + + + + + + + + + + +
+ ) +}, document.getElementById("root")!) + +Object.defineProperty(window, "markdownLifetime", { value: stats }) diff --git a/packages/session-ui/performance/markdown-lifetime/index.html b/packages/session-ui/performance/markdown-lifetime/index.html new file mode 100644 index 00000000000..04aa3cb307a --- /dev/null +++ b/packages/session-ui/performance/markdown-lifetime/index.html @@ -0,0 +1,11 @@ + + + + + Markdown lifetime fixture + + +
+ + + diff --git a/packages/session-ui/performance/markdown-lifetime/lifetime.bench.ts b/packages/session-ui/performance/markdown-lifetime/lifetime.bench.ts new file mode 100644 index 00000000000..46992e30261 --- /dev/null +++ b/packages/session-ui/performance/markdown-lifetime/lifetime.bench.ts @@ -0,0 +1,55 @@ +import { benchmark, expect } from "../../../app/e2e/performance/benchmark" + +for (const scenario of ["mounted", "leave", "shared"]) { + benchmark(`completed Markdown: ${scenario}`, async ({ page, report }) => { + const errors: string[] = [] + page.on("pageerror", (error) => errors.push(error.message)) + await page.goto(`/?scenario=${scenario}`) + await page.getByRole("button", { name: "Admit answer" }).click() + await expect(page.getByRole("button", { name: "Continue", exact: true })).toBeEnabled() + const session = await page.context().newCDPSession(page) + await session.send("Performance.enable") + const before = await session.send("Performance.getMetrics") + await page.getByRole("button", { name: "Continue", exact: true }).click() + await expect(page.locator("body")).toHaveAttribute("data-ready", "true") + await expect(page.locator("body")).toHaveAttribute("data-settled", "true") + const after = await session.send("Performance.getMetrics") + const stats = await page.evaluate(() => Reflect.get(window, "markdownLifetime")) + // The frozen initial baseline used a byte label for this character count. + stats.cacheChars ??= stats.cacheBytes + delete stats.cacheBytes + expect(stats.requests).toBe(1) + expect(stats.responses).toBe(1) + expect(stats.ready).toBeGreaterThan(stats.released) + expect(stats.settled).toBeGreaterThan(stats.released) + expect(errors).toEqual([]) + if (scenario === "leave") { + await expect(page.locator("#survivor")).toHaveCount(0) + await expect(page.getByRole("heading", { name: "Current destination" })).toBeVisible() + if (process.env.MARKDOWN_ASSERT_DISPOSAL === "1") expect(stats.cacheChars).toBe(0) + } else { + await expect(page.locator("#survivor pre code")).toHaveCount(stats.fences) + await expect(page.locator("#survivor")).toContainText("Review complete.") + expect(stats.cacheChars).toBeGreaterThan(0) + if (scenario === "shared") await expect(page.locator("#departing")).toHaveCount(0) + } + const value = (data: typeof after, name: string) => data.metrics.find((item) => item.name === name)!.value + const retained = process.env.MARKDOWN_RETAINED === "1" + if (retained) await session.send("HeapProfiler.collectGarbage") + const heap = await session.send("Runtime.getHeapUsage") + report( + { + ...stats, + readyMs: stats.ready - stats.released, + settledMs: stats.settled - stats.released, + taskMs: (value(after, "TaskDuration") - value(before, "TaskDuration")) * 1000, + scriptMs: (value(after, "ScriptDuration") - value(before, "ScriptDuration")) * 1000, + usedHeapBytes: heap.usedSize, + }, + { scenario, retained, build: process.env.MARKDOWN_BUILD_DIR, revision: process.env.MARKDOWN_REVISION }, + ) + if (process.env.MARKDOWN_SCREENSHOT) + await page.screenshot({ path: `${process.env.MARKDOWN_SCREENSHOT}/${scenario}.png` }) + await session.detach() + }) +} diff --git a/packages/session-ui/performance/markdown-lifetime/playwright.config.ts b/packages/session-ui/performance/markdown-lifetime/playwright.config.ts new file mode 100644 index 00000000000..5ca59beea6f --- /dev/null +++ b/packages/session-ui/performance/markdown-lifetime/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@playwright/test" +import { fileURLToPath } from "node:url" + +export default defineConfig({ + testDir: ".", + testMatch: "*.bench.ts", + outputDir: process.env.MARKDOWN_RESULTS_DIR, + workers: 1, + retries: 0, + timeout: 60_000, + reporter: [["line"]], + use: { baseURL: "http://127.0.0.1:6197", viewport: { width: 1280, height: 900 } }, + webServer: { + cwd: fileURLToPath(new URL("../..", import.meta.url)), + command: + "bun --bun x vite preview --config performance/markdown-lifetime/vite.config.ts --host 127.0.0.1 --port 6197 --strictPort", + url: "http://127.0.0.1:6197", + reuseExistingServer: false, + }, +}) diff --git a/packages/session-ui/performance/markdown-lifetime/vite.config.ts b/packages/session-ui/performance/markdown-lifetime/vite.config.ts new file mode 100644 index 00000000000..8a3c8e39d0f --- /dev/null +++ b/packages/session-ui/performance/markdown-lifetime/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite" +import solid from "vite-plugin-solid" +import { fileURLToPath } from "node:url" + +export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), + plugins: [solid()], + build: { outDir: process.env.MARKDOWN_BUILD_DIR, emptyOutDir: true, sourcemap: true }, + worker: { format: "es" }, +}) diff --git a/packages/session-ui/src/components/markdown-cache.tsx b/packages/session-ui/src/components/markdown-cache.tsx index a87ec081a88..1a8d587484f 100644 --- a/packages/session-ui/src/components/markdown-cache.tsx +++ b/packages/session-ui/src/components/markdown-cache.tsx @@ -1,7 +1,7 @@ import { checksum } from "@opencode-ai/util/encode" import { parseSmallMarkdown } from "@opencode-ai/ui/context/marked-base" import DOMPurify from "dompurify" -import { parseMarkdown } from "./markdown-worker" +import { MarkdownWorkerDisposedError, parseMarkdown } from "./markdown-worker" import { localImagePath } from "./markdown-image" export type MarkdownCacheEntry = { @@ -12,7 +12,10 @@ export type MarkdownCacheEntry = { const max = 200 const cache = new Map() -const pending = new Map }>() +const pending = new Map< + string, + { raw: string; promise: Promise; controller: AbortController; consumers: Set } +>() // Mermaid registers hooks on the shared instance that overwrite link attributes. const purifier = typeof window !== "undefined" ? DOMPurify(window) : DOMPurify const config = { @@ -67,11 +70,12 @@ export function touchCachedMarkdown(key: string, value: MarkdownCacheEntry) { cache.delete(first) } -export async function preloadMarkdown(text: string, cacheKey: string) { +export async function preloadMarkdown(text: string, cacheKey: string, signal?: AbortSignal) { + if (signal?.aborted) throw new MarkdownWorkerDisposedError() const block = { raw: text, src: text } const key = `${cacheKey}:0:full` if (getReadyMarkdown(block, key)) return - await renderCachedMarkdown(block, key) + await renderCachedMarkdown(block, key, signal) } export function getReadyMarkdown(block: { raw: string; src: string }, key?: string) { @@ -98,7 +102,8 @@ export function getReadyMarkdown(block: { raw: string; src: string }, key?: stri } } -export async function renderCachedMarkdown(block: { raw: string; src: string }, key?: string) { +export async function renderCachedMarkdown(block: { raw: string; src: string }, key?: string, signal?: AbortSignal) { + if (signal?.aborted) throw new MarkdownWorkerDisposedError() const cached = key ? getCachedMarkdown(key) : undefined if (key && cached?.raw === block.raw) { pending.delete(key) @@ -106,9 +111,31 @@ export async function renderCachedMarkdown(block: { raw: string; src: string }, return cached } const current = key ? pending.get(key) : undefined - if (current?.raw === block.raw) return current.promise - const promise = parseMarkdown(block.src) + const job = current?.raw === block.raw ? current : startMarkdown(block, key) + const consumer = Symbol() + job.consumers.add(consumer) + return new Promise((resolve, reject) => { + const release = () => { + signal?.removeEventListener("abort", abort) + // A disposed consumer must not cancel another consumer's shared parse. + if (!job.consumers.delete(consumer) || job.consumers.size > 0) return + job.controller.abort() + if (key && pending.get(key) === job) pending.delete(key) + } + const abort = () => { + release() + reject(new MarkdownWorkerDisposedError()) + } + signal?.addEventListener("abort", abort, { once: true }) + void job.promise.then(resolve, reject).finally(release) + }) +} + +function startMarkdown(block: { raw: string; src: string }, key?: string) { + const controller = new AbortController() + const promise = parseMarkdown(block.src, controller.signal) .then((html) => { + if (controller.signal.aborted) throw new MarkdownWorkerDisposedError() const hash = checksum(block.raw) const result = { raw: block.raw, hash: hash ?? "", html: sanitizeMarkdown(html) } if (key && hash && pending.get(key)?.promise === promise) touchCachedMarkdown(key, result) @@ -117,6 +144,7 @@ export async function renderCachedMarkdown(block: { raw: string; src: string }, .finally(() => { if (key && pending.get(key)?.promise === promise) pending.delete(key) }) - if (key) pending.set(key, { raw: block.raw, promise }) - return promise + const job = { raw: block.raw, promise, controller, consumers: new Set() } + if (key) pending.set(key, job) + return job } diff --git a/packages/session-ui/src/components/markdown-worker.ts b/packages/session-ui/src/components/markdown-worker.ts index 568b4d8599e..2c27ff4660b 100644 --- a/packages/session-ui/src/components/markdown-worker.ts +++ b/packages/session-ui/src/components/markdown-worker.ts @@ -54,12 +54,21 @@ const projectTransport = createWorkerTransport { + parses.get(id)?.reject(new MarkdownWorkerDisposedError()) + parses.delete(id) + } return new Promise((resolve, reject) => { parses.set(id, { resolve, reject }) + signal.addEventListener("abort", abort, { once: true }) instance.postMessage({ type: "parse", id, text } satisfies MarkdownWorkerRequest) + }).finally(() => { + signal.removeEventListener("abort", abort) + parses.delete(id) }) } diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx index 1989aa5c6a4..3616215c21c 100644 --- a/packages/session-ui/src/components/markdown.tsx +++ b/packages/session-ui/src/components/markdown.tsx @@ -396,6 +396,7 @@ export function Markdown( const markdown = useMarkdown() const [root, setRoot] = createSignal() const owner = createUniqueId() + const lifetime = new AbortController() const activeCodeKeys = new Set() const completedCode = new Map>() let streamed = false @@ -481,25 +482,31 @@ export function Markdown( } const ready = block.mode === "full" ? getReadyMarkdown(block, key) : undefined - return { key: blockKey, mode: block.mode, ...(ready ?? (await renderCachedMarkdown(block, key))) } + return { + key: blockKey, + mode: block.mode, + ...(ready ?? (await renderCachedMarkdown(block, key, lifetime.signal))), + } }), ) .then((blocks) => ({ text: src.text, blocks, ready: true }) satisfies RenderResult) .catch( () => - ({ - text: src.text, - ready: true, - blocks: [ - { - key: base ?? "fallback", - mode: "full" as const, - raw: src.text, - hash: checksum(src.text) ?? "", - html: fallback(src.text), - }, - ], - }) satisfies RenderResult, + (lifetime.signal.aborted + ? { text: src.text, blocks: [], ready: false } + : { + text: src.text, + ready: true, + blocks: [ + { + key: base ?? "fallback", + mode: "full" as const, + raw: src.text, + hash: checksum(src.text) ?? "", + html: fallback(src.text), + }, + ], + }) satisfies RenderResult, ) }, { initialValue: initial }, @@ -562,6 +569,7 @@ export function Markdown( }) onCleanup(() => { + lifetime.abort() images?.dispose() if (copyCleanup) copyCleanup() const container = root()