|
|
@@ -1,4 +1,4 @@
|
|
|
-# Agent Note: Opt-in reasoning chunk browser stress lane
|
|
|
+# Agent Note: Frame-coalesced reasoning-chunk publication and browser stress validation
|
|
|
|
|
|
Status: implemented
|
|
|
|
|
|
@@ -6,28 +6,38 @@ English | [中文](2026-08-03-opt-in-reasoning-chunk-browser-stress.zh.md)
|
|
|
|
|
|
## Problem
|
|
|
|
|
|
-The browser freeze caused by a long reasoning stream emerges across the fixture async stream, client session reduction, React reconciliation, and the live Think row. Small unit tests prove event semantics but do not expose renderer starvation, while putting a 100,000-chunk scenario in the required [web browser lane](2026-07-24-web-gui-browser-e2e-lane.md) would add a slow, intentionally red reproduction to every pull request. A producer paced by `requestAnimationFrame` also gives the renderer implicit backpressure: when the page stalls, production stalls with it and hides the network-arrival condition that triggers the regression.
|
|
|
+Long reasoning streams continuously produce large numbers of `assistant/chunk` events. Each raw event must be ordered, logged, and folded into `PartialAccumulator` to preserve replay fidelity and the completeness of the final content; React, however, needs only the current accumulated result, not every intermediate state within one browser frame.
|
|
|
+
|
|
|
+Each `yield` in an async stream can create a new microtask boundary, so `Notifier.markDirty()` backed only by microtask batching degrades into rebuilding a `ConversationSnapshot`, notifying `useSyncExternalStore`, and running a React render for every chunk. Even with the live Think row collapsed, 100,000 reasoning chunks can overwhelm the main thread with reconciliation, commit, and layout work. The performance boundary must sit between session ingestion and React publication; it cannot hide the problem by slowing the producer or discarding raw events.
|
|
|
|
|
|
## Decision
|
|
|
|
|
|
-`pnpm run test:web:stress` is the explicit entry point for browser performance reproductions. Its dedicated `vitest.web-stress.config.ts` includes only `apps/web/stress-tests/**/*.stress.ts`; the default unit and web configurations do not collect that suffix. The command builds first because Chromium consumes emitted client artifacts, and `tsconfig.host.json` owns the stress spec because the spec boots the host scaffold.
|
|
|
+`Session.acceptLiveEvent()` appends every raw event immediately and synchronously updates the transcript, `PartialAccumulator`, and other session-derived state. Visible `block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, and `block-end` chunks publish through `Notifier.markFrameDirty()`: the first change schedules one `requestAnimationFrame`, later chunks only continue updating the accumulator, and the frame callback rebuilds one accumulated snapshot from the latest state and notifies subscribers once. `usage`, `finish`, and unknown invisible chunks remain in the event window but trigger no redundant React notifications. Session and history checks share the same visible-chunk classification.
|
|
|
+
|
|
|
+`Notifier` tracks pending publication work with a scheduling kind and generation marker. Ordinary structural events continue to publish in a microtask through `markDirty()`; if a finalized message, tool event, or error arrives while a frame publication is pending, the microtask supersedes it and the old frame callback is invalidated by its generation mismatch. `notifyNow()` likewise invalidates the old schedule to preserve synchronous echo for controlled inputs. Environments without `requestAnimationFrame` fall back to microtask batching. A finalization event may skip one intermediate partial that has not yet appeared, while the published final content and raw event sequence remain complete.
|
|
|
|
|
|
-The reasoning scenario selects the deterministic `?fixture` session and asks an opt-in fixture timing hook to emit exactly 100,000 individual `reasoning-delta` events. The producer has a nominal external cadence of 128 events every 16 milliseconds and repays elapsed-interval debt after a main-thread stall. This approximates bytes continuing to arrive outside the renderer without synchronously preloading a giant fixture queue. A terminal marker proves that the events crossed session reduction and reached the live Think row.
|
|
|
+Keeping the live Think row horizontally pinned to the end of the accumulated text is purely visual alignment and does not require synchronous layout reads on every React commit. An in-component scheduler coalesces consecutive requests into one update every three frames, reads `scrollWidth` and `clientWidth` from the latest DOM, and updates `scrollLeft` directly to the latest position; the fixed visual cadence keeps summary changes readable without allowing browser smooth-scroll animations to accumulate. This throttling applies only to Think's horizontal summary and does not delay Chat body scrolling, history-prepend anchoring, or user-triggered `scrollIntoView`.
|
|
|
|
|
|
-The browser installs a 50-millisecond heartbeat and schedules a DOM event before starting the stream. The final report includes emitted chunks, maximum heartbeat delay, scheduled-interaction delay, and heartbeat samples. Both delay measurements have a 250-millisecond budget. While the regression remains, the opt-in command prints the measurements and exits nonzero; it becomes the acceptance check for the renderer fix without making an already-known performance failure a default CI gate. `DSH_WEB_STRESS_HEADFUL=1 pnpm run test:web:stress` exposes the same scenario in a visible browser.
|
|
|
+`pnpm run test:web:stress` remains keyless, opt-in browser performance evidence. The deterministic `?fixture` session emits 100,000 `reasoning-delta` events at a cadence independent of painting, and a terminal marker proves that the events cross production session reduction and reach the live Think row; a 50-millisecond heartbeat and a pre-scheduled DOM event measure main-thread stalls and interaction latency, respectively, with a 250-millisecond budget for identifying clear regressions. `DSH_WEB_STRESS_HEADFUL=1` lets developers profile the same scenario in a visible browser with the Performance panel; the Vite web shell and tsdown client-plugin bundle always generate source maps, and the module host keeps `client.js.map` alongside every dynamic `client.js` in the build tree, allowing the browser to map samples back to TS/TSX source. The stress lane is evidence for manual performance diagnosis and fix acceptance, not a default CI gate or a substitute for deterministic scheduling unit tests.
|
|
|
|
|
|
-The default fixture unit suite exercises the timing hook with three chunks and fake timers. That small contract pins input validation, interval pacing, concurrency rejection, exact event count, and terminal-marker delivery without carrying the 100,000-chunk workload into `pnpm test`, `pnpm run test:gui`, or `pnpm run test:web`.
|
|
|
+Focused tests pin `Notifier`'s per-frame coalescing, structural-event preemption, invalidated callbacks, and no-rAF fallback, and prove at the `Session` layer that a frame publishes the latest accumulated text only once and that finalization is not followed by a duplicate notification from a stale frame callback. Small fixture unit tests continue to pin input validation, external arrival pacing, concurrency rejection, exact event count, and terminal-marker delivery without bringing the 100,000-chunk workload into the default test suites.
|
|
|
|
|
|
## Alternatives considered
|
|
|
|
|
|
-**Required web browser scenario.** Rejected: the reproduction currently takes tens of seconds and is expected to fail its responsiveness budget, so making it required would block unrelated work before the production fix exists.
|
|
|
+**React transitions, deferred values, or component throttling applied to snapshots.** Rejected: the session source would still notify `useSyncExternalStore` for every chunk, the React render has already occurred before a component decides to defer display, and multiple components consuming the same snapshot would each need to implement the strategy. Visual tail-following throttling for the Think summary occurs after snapshot publication and only reduces the frequency of synchronous layout; it does not implement the data-publication policy.
|
|
|
+
|
|
|
+**Dropping, sampling, or concatenating raw chunks at the ingestion or logging layer.** Rejected: raw `assistant/chunk` events are replayable session facts; changing them would reduce diagnostic and UI fidelity and mix display-frequency policy into the authoritative data layer.
|
|
|
|
|
|
-**Animation-frame pacing.** Rejected after measurement: tying production to paint kept the maximum observed delay below the budget because the producer slowed whenever rendering slowed. That tests a backpressured source rather than the reported continuous-stream workload.
|
|
|
+**Microtask batching alone.** Rejected: consecutive asynchronous `yield` operations can drain the microtask queue between adjacent chunks, making microtask batching approximate one notification per chunk.
|
|
|
|
|
|
-**One synchronous 100,000-event enqueue.** Rejected: it would block the page in the producer itself and would load `FxInbox` with a huge array drained by `shift()`, confounding renderer cost with fixture queue mechanics.
|
|
|
+**Pacing the test producer by animation frames.** Rejected: the producer would slow whenever rendering slowed, giving the page implicit backpressure absent from a real network stream and masking main-thread starvation.
|
|
|
|
|
|
-**A live model or recorded HTTP byte stream.** Rejected for this reproduction: a live stream is nondeterministic, and HTTP-level recording adds fixture cost without improving the target assertion. The in-memory fixture omits HTTP/SSE byte framing but preserves individual asynchronous session events and the production client reduction and React rendering path where the freeze is observed.
|
|
|
+**A live model or recorded HTTP byte stream.** Rejected: live models are nondeterministic, and an HTTP/SSE recording would not improve the target assertion. The in-memory fixture preserves individual asynchronous session events, production client reduction, and the React rendering path while controlling the workload and arrival cadence.
|
|
|
|
|
|
## Consequences
|
|
|
|
|
|
-Developers now have a keyless, repeatable command that reproduces the long-reasoning freeze with an exact workload and emits machine-readable responsiveness evidence. The default suites remain fast and green, but the stress lane must be invoked explicitly during diagnosis and before accepting a renderer fix. Its timing threshold is a browser responsiveness guard rather than a throughput target; hardware changes can alter total duration, while a multi-second heartbeat or interaction delay remains an unambiguous failure.
|
|
|
+The publication rate of streaming `ConversationSnapshot` objects is bounded by the browser's paint rate, so React handles at most one accumulated partial containing all received text per frame; structural events can still publish sooner. Ingestion, ordering, logging, string concatenation, and accumulator updates still run for every raw chunk, so this decision reduces snapshot rebuilding and React work without pretending to solve raw-stream parsing cost.
|
|
|
+
|
|
|
+Horizontal layout reads and writes for the collapsed Think summary run at most once every three frames, and each update moves the summary directly to the latest position; React still commits accumulated snapshots normally, and the summary returns to the first line at finalization. This local visual policy does not change the immediacy of body scrolling or user interactions.
|
|
|
+
|
|
|
+The browser stress lane continues to provide a responsiveness signal from the real assembled application and an entry point for visible profiling, but hardware and scheduling differences make it suitable only as explicit performance evidence. Deterministic focused tests guard publication counts, accumulated content, and preemption order, while the default test lanes remain fast.
|