fix(session-ui): cancel abandoned completed Markdown parse jobs (#46764)

This commit is contained in:
Luke Parker 2026-09-02 18:26:42 +10:00 committed by GitHub
parent 311e32da93
commit 1bd1f72bcf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 755 additions and 28 deletions

View file

@ -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=="],

View file

@ -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.

View file

@ -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<string, SessionMessageInfo[]> = 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()
})
}

View file

@ -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,
},
})

View file

@ -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<MarkdownWorkerResponse>) => void) {
descriptor.set!.call(this, (event: MessageEvent<MarkdownWorkerResponse>) => {
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)
}

View file

@ -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 (

View file

@ -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

View file

@ -206,6 +206,104 @@ story("shares in-flight Markdown rendering without overwriting a reclaimed cache
expect(result.html).toContain("<strong>Shared result</strong>")
})
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)

View file

@ -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:",

View file

@ -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.

View file

@ -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.**`
}

View file

@ -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<MarkdownWorkerResponse>) => void) {
descriptor.set!.call(this, (event: MessageEvent<MarkdownWorkerResponse>) => {
if (event.data.type === "parse" && event.data.id === answerID) {
stats.responses++
held = () => callback.call(this, event)
document.querySelector<HTMLButtonElement>("#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 (
<main style={{ "max-width": "960px", margin: "24px auto", "font-family": "sans-serif", "line-height": "1.5" }}>
<button id="admit" onClick={() => setAdmitted(true)} disabled={admitted()}>
Admit answer
</button>
<button
id="continue"
disabled
onClick={() => {
stats.released = performance.now()
performance.mark("markdown-lifetime-release")
if (scenario !== "mounted") {
stats.disposed = true
setLeaving(true)
}
held!()
held = undefined
const channel = new MessageChannel()
channel.port1.onmessage = () => {
stats.settled = performance.now()
stats.cacheChars = getCachedMarkdown("lifetime:0:full")?.html.length ?? 0
document.body.dataset.settled = "true"
channel.port1.close()
channel.port2.close()
}
channel.port2.postMessage(null)
}}
>
Continue
</button>
<Show when={admitted()}>
<Show when={!leaving()}>
<Markdown
id={scenario === "shared" ? "departing" : "survivor"}
text={answer}
cacheKey="lifetime"
deferUntilReady
/>
</Show>
<Show when={scenario === "shared"}>
<Markdown id="survivor" text={answer} cacheKey="lifetime" deferUntilReady />
</Show>
<Show when={leaving() && scenario === "leave"}>
<Markdown id="destination" text={destination} cacheKey="destination" deferUntilReady />
</Show>
</Show>
</main>
)
}, document.getElementById("root")!)
Object.defineProperty(window, "markdownLifetime", { value: stats })

View file

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Markdown lifetime fixture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./fixture.tsx"></script>
</body>
</html>

View file

@ -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()
})
}

View file

@ -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,
},
})

View file

@ -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" },
})

View file

@ -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<string, MarkdownCacheEntry>()
const pending = new Map<string, { raw: string; promise: Promise<MarkdownCacheEntry> }>()
const pending = new Map<
string,
{ raw: string; promise: Promise<MarkdownCacheEntry>; controller: AbortController; consumers: Set<symbol> }
>()
// 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<MarkdownCacheEntry>((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<symbol>() }
if (key) pending.set(key, job)
return job
}

View file

@ -54,12 +54,21 @@ const projectTransport = createWorkerTransport<Extract<MarkdownWorkerRequest, {
},
})
export function parseMarkdown(text: string) {
export function parseMarkdown(text: string, signal: AbortSignal) {
if (signal.aborted) return Promise.reject(new MarkdownWorkerDisposedError())
const instance = getWorker()
const id = ++nextID
const abort = () => {
parses.get(id)?.reject(new MarkdownWorkerDisposedError())
parses.delete(id)
}
return new Promise<string>((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)
})
}

View file

@ -396,6 +396,7 @@ export function Markdown(
const markdown = useMarkdown()
const [root, setRoot] = createSignal<HTMLDivElement>()
const owner = createUniqueId()
const lifetime = new AbortController()
const activeCodeKeys = new Set<string>()
const completedCode = new Map<string, Extract<RenderedBlock, { mode: "code" }>>()
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()