{{ message }}
Studio: measure where a heavy thread stalls, across engines and thread size - #9016
Merged
Conversation
…ontent Users report Studio and Desktop going sluggish after long generations with code cells and text. That is a statement about content volume, so the new harness varies characters of thread content rather than message count, and the fixture carries the mix the report names: prose, large code fences, tool calls with collapsible output, code-execution result panes, HTML and canvas artifacts, and inline images. The primary metrics are DOM-observable and wall-clock, because Unsloth Desktop is a Tauri webview and not Chromium. PerformanceObserver accepts type longtask on WebKit 26.5 and Firefox 153 without throwing and then never fires, so support is read from supportedEntryTypes; CDP counters are recorded alongside and labelled Chromium-only.
…ne chatter Re-opening the thread throws away every highlighted fence, so repetitions 2 and 3 were measuring a thread that was still building itself: on Chromium at 300K the scroll gesture read 667ms on the first repetition and 1100ms on the two after it, and the difference was the re-highlighting. Firefox 153 emits exactly two scroll-anchoring notices per run at every size. A warning count that grows with the thread still fails; a constant one does not, or the harness could never report a Gecko number.
…ng floored metrics Measured from the end of the gesture, time to settle reads ~50ms at every size on every engine, because the answer is then three frames, which is the minimum the loop can return. From the start of the action it is what a user waits. A count that goes 0 to 4 has answered the question and counts as discriminating. A floored timing that is zero or negative at the smallest size has not: it says the action resolves inside one frame there, which is a metric with no room to move.
for more information, see https://pre-commit.ci
A WebKit page that ran out of memory at 300K on a loaded machine took eight good measurements down with it. The cell is now recorded as crashed, the run continues, and the verdict still fails on it.
for more information, see https://pre-commit.ci
…cess can On a macos-14 runner Chromium finished all three sizes in 90 seconds and then Playwright's WebKit wedged at the smallest size and never came back, which cost the whole matrix. page.evaluate and browser.new_page have no timeout, and SIGALRM does not help: the sync API blocks the main thread inside a greenlet, so the exception lands in the driver and the caller never resumes. The process boundary is the only bound that works, so the docstring says to drive one engine per invocation under an external timeout.
This was referenced Aug 16, 2026
danielhanchen
added a commit
that referenced
this pull request
Aug 17, 2026
* Chat: stop a message delete from re-rendering the whole thread
Deleting one message from a long chat cost 98ms at 25K characters of thread
content and 472ms at 300K, growing 4.8x across that range. The cost was
attributed to the export / rebuild / import round trip in
delete-thread-message.ts. Profiling says otherwise: sampled on Chromium at 300K
characters (220 messages, 43,422 DOM nodes), deleteThreadMessage is 1.8ms of a
498ms delete, and the whole of syncExportedRepositoryToBackend under it is
1.3ms. The other 400ms is one synchronous React render, and the reason it is
that big is that deleting a message re-rendered every remaining message.
Two things caused that, both here rather than in the repository code.
ThreadPrimitive.Messages rebuilds its element array whenever the message COUNT
changes, so every message's wrapper re-renders on a delete. assistant-ui absorbs
that with a bail-out: an element with no props is memoized and the same object
is handed back, and React skips reconciling it. The `components={{...}}` form
never reaches it, because what the render prop returns there is
<ThreadMessageComponent components={...} />, whose props object is freshly
allocated on every render. Rendering through the children form, returning one
shared propless element, means the wrapper re-renders and the message body,
action bar and tooltips underneath it do not. The role and edit-state selectors
are the ones assistant-ui's own ThreadMessageComponent uses, and the component
choice reproduces its getComponent fallback chain for the three components this
thread supplies.
useOwnsResearchMessage selected `thread.messages`, which subscribed every user
message's action bar to every thread change, and then answered its question by
exporting the whole repository once per message. It now selects the answer, so a
change that does not move the answer re-renders nothing, and the export is
shared across the messages at one revision instead of repeated per message. The
question needs the repository rather than the visible list, since a research
reply can sit on a branch the view is not showing, so the export stays.
Measured with the heavy-thread harness from #9016, chromium, three interleaved
A/B rounds, each cell the median of 3 repetitions:
25K chars 98.3ms -> 25.9ms
100K chars 206.5ms -> 34.7ms
300K chars 472.3ms -> 63.5ms
Growth across the range falls from 4.80x to 2.45x. Firefox at 300K goes 615ms ->
58ms. DOM node counts, message counts before and after the delete, console
warnings and stray requests are identical at every size, so what changed is how
much of the tree React walks, not what it produces.
test_deep_research_frontend_contract pinned the exact expression the ownership
question used to be written as. It is repointed at the question and its scope
rather than at the expression.
* Tighten the comments added by this change
---------
Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com>
for more information, see https://pre-commit.ci
Same defect as the thread-weight page: the smoke page this branch adds was missing <script src="/crypto-boot.js"></script>, so crypto-uuid-boot.test.ts fails with "smoke-heavy-thread.html must load /crypto-boot.js". It matters more here than on a normal smoke page. This harness is what the perf numbers are measured on, and a page that lacks the polyfill differs from production in exactly the kind of way that makes a measurement mean something other than it claims. Verified: the named assertion fails before the change, and all four tests in the file pass after it.
Every repetition deleted a message and nothing put it back, so repetitions 2 and 3 ran on a smaller thread than the census recorded before the loop. An instrumented run at 25K read 20, 19, 18 messages at the start of each repetition; the smoke page now exposes restore() and it reads 20, 20, 20. One cycle is 20 messages against 10 content kinds, so at 25K those deletions were taking a whole kind each time. The re-open window closed on three calm frames, which held 7 rAF samples for an action taking up to 1.4s, and the leftover highlighting was absorbed by the untimed gate at the top of the next repetition. It now settles on no long frame and no new highlighted token for a grace period, and reports the time of the last activity so the grace is not added to both ends of every ratio. The token probe is polled rather than read per frame because it is a document-wide query whose cost would otherwise grow with the signal. The recorder decided ownership from a shared running flag, so a callback scheduled by the previous action ran once more under the next one and both loops appended to the same array. It carries a generation token now. Both settle() calls in the menu script compare a MutationObserver flag before the observer microtask has run, so each waits out a full double rAF. The growth axis carries a count of those floors instead of a flag, and the menu total carries two. median() dropped None, so a repetition where the menu never opened was averaged away and the null checks downstream never saw it; it now returns None if any repetition did, and a key that was null throughout stays present. A scroll, jump or re-open that never settled is a harness failure rather than an axis reading not recorded. Expanding the tool panes after waiting for the highlighter mounted two fresh unhighlighted fences per cycle whose work landed in the keystroke window, the next thing timed. Seeding and repetitions share one build_fixture() now.
for more information, see https://pre-commit.ci
The zero branch keyed on floored, which only identifies a timing that had a paint floor subtracted. An unfloored timing does not have one: longest stall ms and worst frame ms read zero at the smallest size whenever the action resolves before the recorder produces a sample, and were then judged as dropped-frame counters, so a noisy 5ms at the largest size read as a rise of 5 and discriminated. harness_failures accepts any single discriminating axis, so that stray millisecond could carry a run in which every valid latency curve was flat. COUNTER_AXES states which axes are counts. Only frames over 33ms is one; everything else is milliseconds. A timing that reads zero at the smallest size is now reported as having no rise to measure rather than being given a counter's credit. Three assertions, each proven red on its own broken tree: timings judged as counters again, no axis classified as a counter at all, and a timing axis classified as a count. The middle one matters because emptying the set would silently turn every counter into a timing and remove the only zero-based axis the liveness verdict has. One correction: the first version of the set assertion required a counter's name not to end in ms, which is wrong, since the counter axis is called frames over 33ms and does. It names the set exactly now, which is the point of classifying it explicitly.
for more information, see https://pre-commit.ci
Making the wall floor a callable put the lambda itself into the growth report. main() attaches that report to results and json.dumps it, so a complete run raised Object of type function is not JSON serializable after every measurement had already been taken, including the new CI smoke. That is my regression from the previous commit and it broke every full run. resolve_floor returns an int, growth uses it, and the report stores the resolved count at each end of the ratio rather than a boolean. A boolean would serialise and say nothing; the counts let a reader check the subtraction instead of trusting it. The reason no test caught this is that none of them serialised the report, so three assertions now do, and one of them covers the callable-floor axis specifically since that is the case that broke. Both proven red: the callable put back into the report, and a boolean marker in place of the count.
… any baseline Two follow-ons from the same review. resolve_floor cast to int. summarise takes a median across repetitions, so a run whose repetitions paid 1 and 2 waits reports 1.5, and truncating that left half a vsync floor in the wall axis. The documented two-repetition configurations are precisely the ones that produce halves. The median is kept as a float, which serialises fine. The noise floor only applied when a counter started at exactly zero. A dropped-frame count going 1 to 2 is a ratio of 2.0, cleared DISCRIMINATION_RATIO, and since harness_failures accepts any single discriminating axis, one incidental frame could carry the CI smoke while every latency axis was flat. A ratio on a counter is only meaningful once there are enough events for it to be about the content rather than about one frame either way, so the floor now applies whatever the baseline, and the reason string says which of the two rules rejected the axis. The floor stays a count of events and is NOT applied to timings, which would silently reject real latency curves that happen to sit at low absolute values. That has its own test. Five assertions, each proven red on its own broken tree: the floor truncated again, the noise floor skipped for nonzero baselines, the noise floor applied to timings as well, and every counter treated as noise, which is what covers the control.
for more information, see https://pre-commit.ci
quiet() and quietUntilIdle() return the elapsed time since this.startedAt, not the time they themselves took, and gestureMs is computed from startedAt as well. All three therefore span the entire recorder window and contain every double-rAF wait in it, and all three declared zero. For the scroll that is twenty vsync floors left in both ends of the ratio, which compresses it hard enough to report a real size-dependent regression as flat. scroll gesture ms, scroll settle ms and jump settle ms now take the measured paint_waits. Counted at runtime rather than declared, because the twenty come from a loop: the literal nextPaint count in the source is one, so any number written in here would have been wrong the same way the zero was. Deliberately NOT applied to everything. jump painted ms starts at a mark taken after begin() and spans one wait while the jump's window holds two, and MENU_JS awaits no paint at all, so its window count is zero while its two floors are real, coming from settle() reading the pre-MutationObserver state on entry for open and again for close. Giving either the window count would subtract a floor the number never contained, or drop one that it did. The rule is that an axis measured from startedAt takes the measured count and an axis measured from a later mark keeps a declared one. Seven assertions, each proven red on its own broken tree: each of the three axes back to zero, jump painted given the whole-window floor, and menu given a window count of zero. That last break also turned two PRE-EXISTING menu tests red, which independently confirms the menu axis really does carry both of its floors. There is an end-to-end case too: with the floors left in, a 16x scroll curve reads as 1.86x.
danielhanchen
force-pushed
the
perf-heavy-thread
branch
from
August 18, 2026 11:27
fce3895 to
b24cb5b
Compare
for more information, see https://pre-commit.ci
This was referenced Aug 18, 2026
danielhanchen
added a commit
that referenced
this pull request
Aug 18, 2026
Two conflicts, both unions, nothing dropped from either side. - studio/frontend/tsconfig.app.json: main added smoke-heavy-thread-main.tsx to include, this branch added smoke-thread-weight-main.tsx. They are separate entry points and both files exist, so both entries are kept. Dropping either would leave one smoke entry out of the typecheck. - tests/studio/test_autoscroll_harness_contract.py: main imports types, this branch imports ast. Both are kept because both are used. This branch does not read quiet() or quietUntilIdle(), so #9016's change to what they return does not move any number measured here. The heavy-thread harness that does read them arrived with this merge and is main's own. Suite is 3954 passed, 4 skipped. The 71 failures in test_chat_autoload_failure_gate.py and test_model_picker_contracts.py reproduce identically on a clean checkout of main at 0775936, so they are not from this branch and are deselected above rather than papered over.
danielhanchen
added a commit
to danielhanchen/unsloth-staging-2
that referenced
this pull request
Aug 22, 2026
…rebuilding all of them first (unslothai#9058) * Studio: stop a long thread rebuilding every message before it can paint * Measure the shift as a residual, and skip a frame the reader scrolled through The correction was measured in document space, following LibreChat#14901. In our viewport that double-corrects: Chromium's own scroll anchoring has usually already moved scrollTop by the inserted height, and adding the full insertion on top of it walked a detached reader 22,897 -> 117,104 and dumped them at the bottom of the thread. Viewport space measures the residual instead, which is single-digit pixels. That exposed the other half: across the transition-deferred gap the reader's own wheel is indistinguishable from a layout shift in viewport space, so a 4000px scroll during a widening was cancelled inside the frame. The hook now counts user gestures and the correction skips any frame one landed in. Found by tests/studio/probe_progressive_anchor.py, added here. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Arm the window on the cold open, and stop leaning on the browser for the scroll correction Four fixes, each with a measurement behind it. 1. The cold open never armed. ProgressiveMessages mounts before the history adapter has delivered anything, so the useState initialiser ran at count 0, declined, and the whole thread then landed in one unbounded commit. Measured in the app: mount at count 0, count 220 about 160ms later, same resetKey, first paint carrying all 220 rows. The window now re-arms on the commit that first fills an empty tree. Gated on the previous count being zero rather than on crossing MIN_PROGRESSIVE_MESSAGES, so it cannot window a conversation the reader is already in the middle of. 300K, chromium, medians of 5: cold open 2498.8ms/220 rows to 270.6ms/16 rows, against a base of 2536ms. 2. The scroll correction assumed the engine had already done the work. It measured the anchor in viewport space, which reports the residual left by CSS scroll anchoring, and dropped any frame the reader gestured through on the grounds that the residual is single-digit pixels. Both hold only where the engine anchors. WebKit implemented scroll anchoring in February 2026, so every shipping Safari and every iOS browser today does not, and Playwright's WebKit 26.5 does, which is why this was invisible. Measured at 150K with overflow-anchor:none forced on the viewport: one dropped frame walked a parked reader 19,259px, and dropping every frame walked them 45,873px, on all three engines. The correction is now a tested pure function that picks its space from a one-off feature probe: viewport-space residual where the engine compensates, document space where it does not, and no frame is dropped in the second case. Same fixture with the fix: 12px and 13px. 3. A thread that shrank under a live window painted nothing. The row loop clamped start to count, which makes it emit zero rows. Measured: dropping a 220-message thread to 10 while the window sat at start 204 painted an empty column for 2 to 8 frames on all three engines. The window is dropped in that commit instead. 4. completeProgressiveMounts sampled the completer set once, and the completers register from an effect, so a caller in the same task as the open returned on an empty set with 16 of 220 rows in the document. It now re-reads the set across frames, registers from a layout effect, and resolves on the commit that actually dropped the window rather than on a two-frame timer started at the call. Also resets adjustImplRef in the autoscroll teardown alongside the other two impl refs. Tests: the correction is now testable arithmetic, with both branches covered. The glue assertions read comment-stripped source for their positive matches too, so commenting the feature out no longer leaves them green, and every source slice requires both of its markers instead of silently widening to the end of the file. npm test 3514 passed, typecheck, build and biome clean. * Count every scroll the hook did not make, and resync even when the correction is zero Two more paths where the scroll correction reads the wrong thing, both raised in review and both reproduced here. A scrollbar drag, PageUp, the arrow keys and middle-click autoscroll reach the autoscroll hook as nothing but a scroll event. They detach the reader through onScroll's 2px accumulator, but they never bumped the user-gesture counter, which only onWheel and onTouchMove touched. A correction measured across one of them therefore read the reader's own movement as a layout shift and reversed it. onScroll now counts any scroll with a non-zero delta. The two scrolls that are not gestures stay excluded by arithmetic rather than by a flag: the corrective write advances lastScrollTop with itself, and a native scroll-anchoring adjustment is advanced past by the resync below, so both arrive with delta 0. And a widening that native scroll anchoring absorbed in full needs no scroll write, but the browser still moved scrollTop by the whole inserted height and still fires a scroll event for it. Measured on a 400px scroller with 2000px inserted above the scroll position: one scroll event carrying the new offset, on Chromium 151, WebKit 26.5 and Firefox 153. The old early return left lastScrollTop a whole insertion behind, so that event arrived as a large downward scroll nobody made, and a reader who had deliberately detached within the 24px re-attach threshold, which detachFromBottom puts them at whenever the composer grows, was silently re-attached and yanked to the bottom on the next widening. The mount window now reports every widening including the zero ones, and the hook resyncs its bookkeeping whether or not it wrote. npm test 3516 passed, typecheck and build clean. * Do not believe an empty completer set until it has been looked for completeProgressiveMounts re-read the set across frames but still returned at the first empty reading, so the one case it was rewritten for stayed broken: a caller in the same task as a cold open finds the set empty because the history load has not finished, and two animation frames is nowhere near the roughly 160ms that load takes. The remaining passes never ran, because the loop exited on the first pass. An empty set is now only believed after 400ms of looking, while a set that has been non-empty and has since drained is believed immediately. A caller on a settled thread therefore pays 400ms, which is the right direction to be wrong in: the alternative is a screenshot or an export of a conversation that is not there yet, and anything reading the whole thread out of the DOM is already doing something far more expensive. npm test 3516 passed, typecheck and build clean. * Take the browser out of the scroll correction instead of trying to predict it The correction measured the residual left by CSS scroll anchoring and dropped any frame the reader had scrolled through, on the grounds that the residual is single-digit pixels. That is true exactly while the browser compensates, and this code was not in charge of whether it did. Two ways it does not, both measured at 150K characters on the 9016 fixture: - No shipping Safari and no iOS browser has scroll anchoring at all today. WebKit implemented it in February 2026 and Playwright's WebKit 26.5 has it, which is why this was invisible here. Standing in for those builds with overflow-anchor:none, one dropped frame walked a parked reader 19,259px and dropping every frame walked them 45,873px, on all three engines. - Anchoring is also suppressed per frame on engines that do have it, including after a programmatic scroll, which is what a scrollbar drag, PageUp, the arrow keys and middle-click autoscroll all become. On Chromium 151 with anchoring available and a reader scrolling that way through the build-in: 45,161px of drift, because those frames were both uncompensated and skipped. Counting those scrolls as gestures, which is what the previous commit did, made this worse rather than better: it added skips to frames that needed the correction most. No measurement taken inside the frame can tell a compensated frame from a suppressed one, so the browser is taken out of the loop rather than modelled. The viewport sets overflow-anchor:none for as long as the mount window is open and gets it back the moment the window closes, so a settled thread is exactly the thread that shipped before. With nothing else moving scrollTop, the correction is document space, which subtracts the reader's own movement arithmetically instead of dodging it, so no frame is ever skipped. That removes the feature probe, the gesture counter and the whole notion of a skipped frame. This is the shape LibreChat#14901 uses. Measured on the same fixture and reader, native anchoring available, all three engines: parked reader 12px drift (chromium), 6 to 13px (webkit) scrollbar or keyboard 4,081 to 4,226px (chromium), all of it the reader's own scrolling against 45,161px before continuous wheel 4,612 to 4,852px (chromium), 4,489 to 4,819px (webkit), likewise their own scrolling npm test 3514 passed, typecheck, build and biome clean. * Measure the anchor against its scroll container, and settle waiters when the thread goes away Three more from review. getBoundingClientRect().top is measured against the window, so it also moves when the scroll container moves, and this container moves for reasons that have nothing to do with the thread: the composer grows a line, the mobile browser chrome slides away, a parent relayouts, the window is resized. Any of those landing between the capture and the widening commit read as content inserted above and were corrected away, moving a detached reader for no reason. Both ends now go through one sampleAnchor that subtracts the container's own top. completeProgressiveMounts awaits a promise this component resolves from an effect. If a thread switch or a navigation unmounted the component first, the layout effect's cleanup removed the completer from the set but left that promise pending forever, so a DOM capture that raced an unmount hung rather than completing. Outstanding waiters are now flushed on unmount, and the ref is emptied by the flush rather than by the effect that schedules it, so a cancelled frame leaves them to be settled rather than dropping them. probe_progressive_anchor.py cannot run on this branch, because it drives 9016's fixture and 9016 is not merged. It now exits with a message naming the three files it needs instead of a bare ModuleNotFoundError. Not fixed by vendoring a copy of the harness: two copies of a measurement harness is how the two copies stop agreeing, and this probe's numbers are only comparable to 9016's while there is exactly one of it. npm test 3515 passed, typecheck, build and biome clean. * Disarm native anchoring at capture time, not from a layout effect The layout effect that set overflow-anchor:none on the viewport did not run in time, and the window it left open was the worst possible one: the first one. This component is a descendant of the viewport element, so on the commit that mounts them both, React runs this subtree's layout effects before the viewport's own ref callback. viewportRef.current is still null, the style is silently skipped, and it only lands on the next commit, which is the first widening. Measured on that version: computed overflow-anchor stayed auto from the first painted row at +305ms until the first widening at +803ms, so that widening ran with anchoring live and the document-space correction was applied on top of the browser's own. A reader who scrolled 4000px inside that window was left 776px short of where they parked, and one whose whole gesture landed inside it was carried back to 24px from the bottom of a 118,004px thread. Neither happened on the merge base. The style is now set in the same requestAnimationFrame that captures the anchor, immediately before the sample it would otherwise invalidate. That frame runs after a paint, so the ref is populated by definition, and it is by construction before any widening rather than merely soon. The layout effect keeps only the restore, and an unmount cleanup restores it too. Measured after, 300K, chromium, three repetitions, the reader's whole 4000px gesture landing inside the first mount window while 110,625px of content is inserted above them: parked at 4000px from the bottom, ends at 4028px, worst single frame 4px. The merge base under the same gesture never widens at all and holds 4000px. npm test 3623 passed, typecheck, build and biome clean. * Hold still the row the reader can see, and compensate the intervals between widenings Two findings from review, one of which turned out to be the other's cause. Disabling native scroll anchoring for the whole mount window means nothing absorbs a relayout above the fold that is not a widening: Streamdown replacing a pre when Shiki finishes highlighting it, KaTeX resizing a formula, an image landing. The autoscroll hook does not cover it either, because its mutation path pins a following reader and deliberately leaves a detached one alone. Measured by injecting a 600px height change into one row above a detached reader while the window was open: the reader moved the full 600px, against 0px on the merge base where native anchoring absorbed it. The correction now also runs between widenings, one frame later than the movement, which is invisible for a single reflow and is what keeps it to one getBoundingClientRect and one scrollTop read per frame. The first attempt at that measured nothing, and the reason is the second finding. The anchor was the first row in the list rather than the first row the reader can SEE. Widening prepends above everything, so for a widening any row will do; a relayout does not, because a row growing between the topmost row and the fold moves the reader while leaving the topmost row exactly where it was. Both paths now hold the first visible row, and that improved the widening correction too: the whole-gesture-inside-the-first-window case, three repetitions at 300K with 110,625px inserted above a reader parked 4000px from the bottom, goes from 28px of drift and a 4px worst frame to 0px and 0px. tests/studio/probe_progressive_reflow.py is the probe that found it, included with its own guards, since both of the things it initially got wrong produced a confident clean zero. npm test 3625 passed, typecheck, build and biome clean. * Drop a window that is past the end instead of narrowing it back to a chunk boundary A window whose start is at or past the end of the thread already causes every row to be rendered, because the row map falls back to no restriction. The STATE was left saying start 204 though, so the next widen clamped to count and subtracted a chunk: against a thread that had shrunk to 100 messages that is start 68, and rows 0 to 67, which the previous commit had just mounted, are unmounted again. Nothing in this design is allowed to unmount a row, and this was the one path that did. Fixed at both levels, because they protect different things. widen now returns null for any window at or past the end, which is a property the state machine can hold on its own and is covered by a test that asserts monotonicity for every reachable window and count rather than for one example. And the glue reconciles the stale state during render instead of leaving it for the next frame, so the committed state matches the tree that was committed with it. npm test 3627 passed, typecheck, build and biome clean. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Close four gaps in the between-widenings compensation All four are in the sampler added a commit ago, and all four have the same shape: with native anchoring off for the duration of the mount window, anything that moves content above a detached reader and is not seen here is kept by the reader in full. A widening now hands the sampler a post-correction baseline instead of nulling it. Nulling meant the next frame merely re-picked and re-based, so an image or a Shiki block landing between the widening's layout effect and that frame was folded into the new baseline and never corrected. Visibility is tested by the anchor's own box rather than by its top offset. A row taller than the viewport, scrolled just past, can have its top within a viewport of the fold while none of it is on screen, and a reflow between it and the first visible row then moves the reader while leaving it still. The anchor descends to the fold rather than stopping at the row. A reader partway through a long answer has that row's top ABOVE them, so an image or a code block earlier in the same message moves everything they can see and leaves the row's top exactly where it was. The descent is bounded at eight levels and runs once per re-pick, not per frame. And a shrink the browser already clamped is no longer corrected twice. Content above a reader near the bottom shrinking is the one case where the browser still moves scrollTop with anchoring off, because the offset they were at stops existing. The document-space delta reports the whole shrink regardless, so the sample now carries the container's maximum scrollTop and the correction subtracts what the clamp already absorbed. Re-measured at 300K on chromium, three repetitions each, all against a reader parked 4000px above the bottom: a 600px reflow injected above them nets to 0px; the whole 4000px gesture landing inside the first mount window while 110,625px is inserted above them ends at exactly 4000px with a 0px worst frame; and 9016's own anchor probe, 94,207px inserted, reports 0px drift and a 0px worst frame. npm test 3635 passed, typecheck, build and biome clean. * Never leave a frame without a baseline, and keep the row as a fallback anchor Two more gaps in the same sampler, both raised in review. An anchor that leaves the viewport was cleared and re-picked on the NEXT frame, which leaves a frame with no baseline, and a reflow landing in it is folded into the new baseline and kept by the reader. It is now re-picked and sampled in the same tick. That is the same mistake as nulling after a widening, in a second place. And the widening correction dropped itself whenever the captured node had been replaced since the capture. That became much more likely with the descent added a commit ago: the anchor is now often the very pre element Streamdown replaces when Shiki finishes highlighting it, and a transition-deferred widening leaves plenty of room for that to happen. Since anchoring is off, dropping the correction moves a detached reader by a whole chunk of prepended rows. The row the anchor sits in is now captured alongside it and used when the anchor is gone; a data-role row is not replaced in place by anything. Re-measured at 300K on chromium, three repetitions each: 9016's own anchor probe reports 0px drift and a 0px worst frame with 94,207px inserted above a reader parked 4000px from the bottom, and a 600px reflow injected above them nets to 0px. npm test 3635 passed, typecheck, build and biome clean. * Say what the fold descent does not cover, so the bound is known Review asked for a caret point at the fold to cover lines reflowing inside a single tall leaf block. Declined, and the reasoning belongs next to the code rather than only in a thread: the residual is one paragraph's height change, for a detached reader, during the few hundred milliseconds a window is open, and the fix costs an engine-conditional per-frame measurement whose own correctness is harder to check than the element-based one. * Give the idle sampler the same fallback and the same re-basing the widening path has Two more from review, both places where the between-widenings sampler was missing something the widening path already had. It kept no stable fallback. When Streamdown replaces the held pre after Shiki finishes highlighting it, the sampler re-picked and re-based AFTER the replacement's own reflow, so with anchoring off the reader kept that height change. It now holds the row alongside the anchor, exactly as the widening capture does, and corrects from the row when the anchor is gone. One holdAnchor builds both, so the two paths cannot drift apart. And it returned early on a frame with nothing to correct, which left the baseline's scrollTop at wherever the reader was several frames ago. anchorCorrection's clamp term reads that scrollTop, so a later shrink near the bottom had the wrong amount subtracted from it. The baseline is now re-based on every frame, correcting frames and no-op frames alike. Re-measured at 300K on chromium, three repetitions each: 9016's own anchor probe reports 0px drift and a 0px worst frame with 94,207px inserted above a reader parked 4000px from the bottom, and a 600px reflow injected above them nets to 0 or 1px. npm test 3657 passed, typecheck, build and biome clean. * Say why the row is the fallback rather than a point below the replaced block Review asked for a stable point below the replaceable content instead of the message row. Declined, and the reasoning belongs next to the code: an anchor below the replaced block would catch its height change, but it would also catch the block growing downward past the fold, which does not move anything the reader is looking at. That trades an error in the common case for one in a rarer case. The residual either way is one code block's height change, for a detached reader, during the few hundred milliseconds a window is open. * Drop the useMemo import this change added to thread.tsx and never used * Stop citing an edge probe that is not in the tree * State what the clamp term cannot know about a reader scrolling through a shrink * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: size the first mount commit against the shortest rows, not a fixture INITIAL_MESSAGES was documented as "roughly eight viewports of overscan", which is true of unslothai#9016's fixture and of nothing else: that fixture's messages are large. The number that decides whether the first commit can paint a gap is how tall 16 of the SHORTEST rows are, and that is now measured. A one-word message is 103px in this thread, so 16 of them are 1639px, and with the viewport's 48px top inset and 165px bottom spacer the first commit fills every viewport up to 1890px of clientHeight. Measured on a 144-message thread whose last 24 messages are one-word replies, Chromium 151 / Firefox / WebKit: 0px of gap at 900, 1080, 1440, 1800, 1840 and 1860px. Above 1890px it does undershoot -- 10px at 1900, 110px at 2000, 270px at 2160, for 287 to 499ms until the first widening chunk closes it -- and the merge base paints nothing at all for 1318ms on the same fixture, so that is left as the trade it is. probe_compact_tail_gap.py is the probe. Stated plainly rather than implied: it CANNOT RUN on this branch as it stands. It drives smoke-heavy-thread.html, which lives in unslothai#9016 and is not merged, and it needs two methods that harness does not have yet, seedCompactTail(chars, tailMessages) and gapMetrics(). It names both in a preflight check rather than failing as a bare JS error, and its docstring says so. This is the same dependency probe_progressive_anchor.py and probe_progressive_reflow.py on this branch already carry, so it is the shape this branch has already settled on rather than a new one. The two methods are being folded into unslothai#9016 so the probe becomes runnable there; the fixture used to take these measurements is deliberately not duplicated here, since copying it would fork 767 lines of an unmerged harness. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Quote the compact-row heights as the pair they are, not their average The doc comment gave a one-word row as 103px. That is the MEAN of the two heights these rows actually have: 126px for a user row and 80px for an assistant row, which differ in padding. Sixteen rows, eight of each, are 1643px rather than the 1639px the average produced. The floor of 1890px is unaffected, because INITIAL_MESSAGES is even and the average is exact on a balanced set. The reason to correct it anyway is that rows-times-103 is wrong by up to 23px at an odd count, so anyone recomputing the floor after changing INITIAL_MESSAGES would get a number that looked derived and was not. Measured on the harness in unslothai#9016 by its owner while landing seedCompactTail. * Record that the idle anchor sampler no longer reproduces its own measurement The comment claimed that without this effect a detached reader kept the full 600px of an injected height change. Re-measured, that no longer happens: ablating the whole effect leaves grow 600, grow 3600 and shrink 1200 identical to head within noise. The logic is not wrong, the ordering is. The widening capture effect is declared first, so React registers its rAF first on every commit and its callback runs before this one's, leaving anchorRef.current non-null on every frame the window is open. This body returns at its first line: instrumented, 0 of 1110 frames across chromium, firefox and webkit got past it. Confirmed causally by swapping only the two effect declarations, after which it runs about 5 times per repetition. Kept rather than deleted, because the widening path it defers to has no visibility test, so this is the only thing that would catch a reflow on a frame with no widening pending. The comment now says so, and says that reordering those two effects switches it on. * Tighten the progressive-mount comments Same explanations, fewer lines. Every measured number, the anchoring rationale and the unexercised-branch warnings are unchanged in substance. * Write the compact-tail probe's reports as utf-8 The three write_text() calls used the platform default encoding, which is cp1252 on Windows, so a report gaining a non-ASCII byte would fail there and nowhere else. tests/test_source_read_encoding.py gates exactly this and was red on Repo tests (CPU). * Tighten the progressive mount comments Same code, shorter prose. Collapses the multi-paragraph blocks in progressive-mount-controller.ts, progressive-messages.tsx and the two progressive-mount test files to the point each was making, and drops restatements of what the code already says. Every measured figure the constants rest on is kept. * Studio: close three progressive-mount assertions that could not fail The mutation matrix over the mount window had five ablations no assertion caught. Three were real holes, all the same shape: an assertion matched the whole file where the invariant belongs to one effect, so a second occurrence elsewhere in the file kept it green. - The widening step is now required to be scheduled on a frame INSIDE the widening effect. A timer there runs before the commit it follows has painted, so the anchor capture samples the previous layout and the viewport ref the disarm needs is not populated yet, and the cleanup cancels a frame, so a window closed between the commit and the callback would widen anyway. - Restoring overflow-anchor is now required in the effect that runs on the CLOSING commit, not merely somewhere in the file, where the unmount cleanup was satisfying it alone. Without the close path native anchoring stays off for the rest of the thread's life with nothing compensating, since both corrections stop with the window. - The completer search interval is now required to be a positive constant that outlasts a cold open's history load. Sampling with a zero deadline keeps both previously asserted lines and still returns on the first pass, which is the read-once failure again. Each is red on exactly the ablation it names and green here. The remaining two ablations change no behaviour: one edits only a comment, and the other disables a sampler that declaration order already keeps from running. * Studio: size the progressive mount's first commit on rows that render The initial tail was chosen by counting messages, but a message whose role the thread supplies no component for paints nothing: threadMessageKind returns "none" and ThreadMessage renders null, so the row has no height. Both importers preserve the system role (chat-import.ts, openwebui-import.ts), so an imported conversation whose last 16 messages are system entries opened on sixteen zero-height rows and no conversation at all until a later widening frame rescued it, which is the stall this window exists to remove rather than cause. Size the tail by walking back until it holds a full tail of renderable rows. Renderability comes from threadMessageKind through the new rendersAsRow, so there is no second list of roles to drift from the renderer. Reaching further back only ever mounts more, so the window still only widens. A thread with fewer renderable rows than one tail is not windowed at all. The predicate is read imperatively off the store, not through useAuiState: a selector touching the messages array runs on every store write, which would walk the thread once per keystroke and once per streamed token. * Studio: bound the progressive mount's first commit, and measure the reopen paint floor Sizing the first commit on renderable rows walks back across every message that does not render, and that walk was unbounded: sixteen visible messages followed by two hundred system entries walked to index zero, so the commit rebuilt every provider in the thread. That is the bound this window exists to create, so cap the span at four tails. A thread has to be three quarters non-rendering across its whole tail before the cap binds; below that the window is exactly the renderable-row one. When it does bind the first commit shows whatever renders inside the cap and widening reaches the rest within a few frames, which is bounded either way. The heavy-thread smoke declared a fixed floor of one double-rAF wait for 'reopen ms'. That was right while the tail arrived in a single commit. The mount window brings a long thread back over several frames, and 'reopen ms' runs until the message count is whole, so it now spans one wait per commit: one at 25K and ten at 100K. Read the count the reopen loop already reports instead of declaring it, and resolve the declaration per row so a hand written literal is still caught when it drifts. * Studio: leave no style attribute behind when the mount window hands scroll anchoring back removeProperty empties the inline declaration but keeps the attribute, so a converged viewport carried style="" where the shipping one carries no style at all. Inert, but it was the only difference a whole-document structural digest could find between this branch and its merge base at 100K and 300K: 45,014 normalized lines, one changed line, against a null control that scored exactly zero. Byte-identical is worth being able to state rather than footnote. Both hand-back paths now go through one helper, and the test asserts there is no second removeProperty outside it, so a future path cannot reintroduce the residue. * Studio: keep the progressive mount's frames in the reopen convergence metric `reopen ms` had its paint floor read from the run: `_floor_from("reopen", "paintWaits")` subtracted one ~33ms vsync floor for every double-rAF wait the poll loop paid. That is right only while the wait count is a property of the instrument. It is not. The loop polls in the same rAF queue as the application's own commits, so on a build that brings a long thread back over several animation frames the count is a property of the APPLICATION: 1 on a single-commit rebuild, and 1 at 25K, 8 at 100K, 24 at 300K on a progressive mount. So the subtraction was asymmetric between the two arms of a comparison and grew with the axis being varied. On the 220-message fixture it removed 33.3ms from the single-commit arm's 300K cell and 796.8ms from the progressive one's, which turned a reopen that is 6.8% slower into one that reads 28.7% faster, and turned a 25K -> 300K curve that rises faster than the single-commit build's (7.60x against 7.08x) into one that rises slower (5.07x). A floor removes a constant the instrument adds; the moment it tracks the thing being measured it stops being a floor and starts being the measurement. Only the observation floor comes out now, and it is a constant: reopen is driven by a React state update, so the count check straight after openThread() always still sees the unmounted tree and the loop always waits out exactly one paint before a finished reopen can be observed at all. Every wait past that one is a frame the application spent committing rows and stays in the number. `reopen wall ms` had the same defect through the generated wall axes, which take the recorder window's measured `paint_waits` (2 -> 25 across the ladder on head). WALL_FLOOR_OVERRIDES pins reopen to its two terminal observation waits, one per loop, and leaves every other action on the measured count, where those waits really are the harness idling between driven steps. `paintWaits` is still reported and still checked, now as a lower bound rather than as equality: subtracting a floor the metric never waited out is the failure, paying more waits than are subtracted is the normal state of a progressive mount. --------- Co-authored-by: danielhanchen <danielhanchen@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothshared@gmail.com> Co-authored-by: oobabooga <112222186+oobabooga@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

What this is
Users report Studio and Desktop going sluggish "after long generations with any code cells and/or text". That is a statement about how much content a thread holds, not about how many messages it has, so this adds a harness whose axis is characters of thread content and whose fixture is the mix the report names.
tests/studio/playwright_heavy_thread.pyplusstudio/frontend/smoke-heavy-thread.{html,tsx}mount the realThread, seed it to a target size, and time six scripted actions on it across three browser engines at three sizes.It measures, it does not gate. It prints the table and exits 0 on any timing. It exits non-zero only when the harness itself is broken: the seed did not land, a kind of content is missing from the fixture, an element it drives went missing, or no axis rose with the thread, which would mean it is measuring nothing.
The fixture
Built in whole cycles of one message per kind, roughly 26K characters a cycle, so every size holds every kind in proportion. Building "blocks until the budget runs out" instead would give the smallest size only the first few kinds, and the curve would then be reporting a change of fixture as well as a change of size.
Per cycle: long prose, a large Python fence, a large TypeScript fence, a
pythontool call with a code-execution result pane, acode_executiontool call with a bash result pane, a fullhtmldocument that Studio collapses into an artifact card, arender_htmltool call (the HTML canvas artifact), ansvgfence that also renders an inline preview image, two image content parts (one repeated PNG and one unique to the block), and a very long single-line JSON fence.Two limits, stated because they change what the numbers mean:
<iframe>lives inArtifactSurfaceon the chat page and loads its content from/api/inference/artifact-preview-frame, so it cannot exist on a backend-free smoke page. What is measured is the in-thread cost of an artifact.pythonresults carryimages: []. A non-empty list makes the card fetch each image from the backend, which would put a network round trip inside a timed region.Why these metrics
The interesting engines are not Chromium. Unsloth Desktop is Tauri: WebView2 on Windows, WKWebView on macOS, WebKitGTK on Linux. So every primary number here is DOM-observable and wall-clock:
setTimeoutloop. The main thread cannot answer the timer while it is busy, so the gap is the block.requestAnimationFramecallbacks.CDP counters (
LayoutCount,RecalcStyleCount,LayoutDuration,RecalcStyleDuration,TaskDuration) and the Long Tasks observer are recorded as well and every one of them is labelled Chromium-only in the table.Three things were measured while building this and are worth having in the record:
PerformanceObserver.observe({ type: "longtask" })does not throw on WebKit 26.5 or Firefox 153. It is accepted and then never fires. Atry/catchtherefore reports both as supporting long tasks, and both then report zero long tasks, which reads as "no jank on the engine Desktop ships on macOS and Linux" rather than as "this API does not exist there". Support is read fromPerformanceObserver.supportedEntryTypes, which listslongtaskon Chromium alone.setTimeoutloop ticks about 150 times a second, costs nothing measurable on any of the three engines, and reported the same 120ms synthetic stall on all three.Engines
chromiumis the proxy for WebView2, that is Desktop on Windows.webkitis the proxy for WKWebView and WebKitGTK, that is Desktop on macOS and Linux.firefoxis a control, so a number that moves on one engine only can be told apart from a number that moves everywhere.Playwright's WebKit is a proxy, not the webview Desktop embeds. It is Apple's WebKit built for Playwright, driven headless, with no Tauri IPC layer and no WebKitGTK compositor. Read it as "JavaScriptCore plus WebKit layout", not as "Unsloth Desktop on macOS".
Desktop Linux under Wayland is worse than any number here.
studio/src-tauri/src/linux_webkit.rssetsWEBKIT_DMABUF_RENDERER_FORCE_SHM=1on WebKitGTK 2.44 and later, forcing the software rendering transport. Everything below ran on a normal compositor path.Ranked measured bottlenecks
From 25K to 300K characters of thread content, that is 20 to 220 messages and 4,048 to 43,422 DOM nodes. Local numbers are the median of 3 repetitions per cell; the attribution column is Chromium's
Performance.getMetricsand exists on no other engine.document.bodygoes topointer-events: nonewhile the menu is open, so the Radix modal layer invalidates style for the whole thread, twice, for a three-item popoverthread.export()to repository tothread.import()round trip, and it is O(messages) wherever the target sitsstudio/frontend/src/index.cssforcescontent-visibility: visible !importanton every code block, so the per-gesture cost is roughly per pixel and the growth shows up as rough frames rather than as a longer gestureThe two that dominate, the menu and the re-open, are also the two that grow fastest, and both are the same shape of problem: an operation whose cost is charged against every message in the thread rather than against the thing the user touched.
Local run, three engines, three sizes, median of 3 repetitions
Longest stall, ms (median of 3 repetitions)
Wall clock for the whole action, ms (median of 3)
Ratio, smallest size to largest
Fixture census, identical on all three engines
Which metrics discriminated, and which did not
The rule this harness is built to: a metric that does not respond to twelve times the content is not measuring the thing being varied, and has to be fixed or dropped rather than reported as a flat curve.
Discriminated, on all three engines, locally and on every runner that produced a column
Did not discriminate, with the reason in each case
Cross-platform, on GitHub-hosted runners
Replicated onto
danielhanchen/unsloth-staging-2withpr_review/staging_ci.pyand run on three runners, 2 repetitions per cell, one engine per invocation. Runner shapes as the jobs report them:ubuntu-latest4 cpus, Intel Xeon Platinum 8573C, 17 GB, Linux 6.17;windows-latest4 cpus, AMD EPYC 7763, 17 GB, Windows 10.0.26100;macos-143 cpus, Apple M1 (virtual), 8 GB, Darwin 23.6. Every cell that produced a number passed the harness's own validity gates.There is no macos-14 WebKit column, and that is a measurement rather than an omission. Playwright's WebKit on
mac14-arm64is a frozen build that the installer itself warns about, and it wedges on this fixture at the smallest size and never returns. The first attempt spent two hours there and was cancelled, which cost the Chromium columns that had already finished in 90 seconds as well. The harness now documents driving one engine per invocation under an external bound, the workflow does that, and the run below shows the result: macOS Chromium and Firefox both completed all three sizes, and WebKit was cut off after 30 minutes at 25K.Longest stall, ms, at 300K characters
chromium
webkit
firefox
chromium
webkit
firefox
chromium
webkit
firefox
Wall clock for the whole action, ms, at 300K characters
chromium
webkit
firefox
chromium
webkit
firefox
chromium
webkit
firefox
Frames over 33ms, ms, at 300K characters
chromium
webkit
firefox
chromium
webkit
firefox
chromium
webkit
firefox
Ratio, smallest size to largest, longest stall
chromium
webkit
firefox
chromium
webkit
firefox
chromium
webkit
firefox
Repetitions and runner shape
WSL
There is no WSL number here, because WSL is not a GitHub-hosted runner and I did not measure one.
The nearest available proxies are both wrong in a way worth naming rather than papering over.
ubuntu-latestruns the same Linux userland and the same WebKitGTK family that Studio Desktop embeds under WSL, so it is the closer proxy for the code path;windows-latestis the right host but the wrong userland, since under WSL the app is the Linux build. Neither reproduces WSLg, which composites through an RDP channel to the Windows host, and neither reproducesWEBKIT_DMABUF_RENDERER_FORCE_SHM=1, whichstudio/src-tauri/src/linux_webkit.rssets on WebKitGTK 2.44 and later and which forces the software rendering transport. Taken together those two say a WSL number would be no better than the Linux column and most likely worse, but that is reasoning, not measurement, and it is not going in a table.Reading the absolute numbers
The local run above is on a shared machine at a load average around 60, and against the vite dev server: React in development mode, nothing minified, unbundled modules. Absolute milliseconds are therefore higher than a packaged Studio's. The ratios across sizes and the ranking across actions are the result; the milliseconds are context.
Every number here is against
mainplus this harness and nothing else. The harness seeds a static thread and measures interaction cost on it, so it does not exercise the streaming paths, and it is not a baseline for any streaming change.CI
studio-frontend-ci.ymlgains a Chromium-only, two-size, one-repetition run of the harness, next to the existing autoscroll and research smokes. That is enough to prove the fixture still renders every kind of content it claims to and that the curve still rises, which is all a PR gate can afford. The three-engine, three-size run takes tens of minutes and belongs on a runner asked for it deliberately.tests/studio/test_heavy_thread_harness_contract.pypins the rules the harness is held to, in the same spirit astest_autoscroll_harness_contract.py: every action has a growth axis, no growth axis and no pass/fail decision rests on a Chromium-only metric, the Chromium-only table rows say so in their own label, the paint floor is measured and subtracted, the verdict asserts the fixture rather than just its size, and the re-open is asserted to have really unmounted.Review round: the verdict, and what it can and cannot see
This harness exits 0 on any timing and non-zero only when it is measuring nothing, so the verdict logic IS the product. Review found several ways it could report a broken or flat run as a live one. All are fixed and pinned.
Paint floors are measured, not declared.
growth()subtracts one ~33ms vsync floor per double-rAF wait a metric is clocked across, and that count was a hand-written integer per axis. Three were wrong:reopen msdeclared 0. Reopening is a React state update, so the count check straight afteropenThread()always still sees the unmounted tree and the loop always pays at least one__nextPaint().wall msaxes declared 0 for every action.MENU_JSopens the recorder before opening the menu and closes it after closing it, so it crosses the same two waitsmenu open+close mscorrectly declares.scroll gesture ms,scroll settle msandjump settle msdeclared 0 while spanning the whole recorder window.quiet()andquietUntilIdle()return elapsed-since-startedAt, not their own duration, andgestureMsis computed fromstartedAttoo, so all three carried the scroll's twenty paint waits. With those floors left in, a 16x curve reads as 1.86x - a real size-dependent regression reported as nearly flat, which is precisely the failure this harness exists to prevent.The recorder now counts the waits each window is clocked across and the axes read that count off the row. Measured rather than declared because the twenty come from a LOOP: the literal
__nextPaint()count in the source is one, so any number written in by hand would have been wrong the same way the zero was.floor_declaration_problemscompares declaration against observation for every engine and size and fails the run on a mismatch.Not applied uniformly.
jump painted msstarts at a mark taken afterbegin()and spans one wait while the jump's window holds two;MENU_JSawaits no paint at all, so its window count is zero while its two floors are real, coming fromsettle()reading the pre-MutationObserver state on entry for open and again for close. The rule is that an axis measured fromstartedAttakes the measured count and an axis measured from a later mark keeps a declared one, and both halves have tests.A counter rising from zero has to say something. No ratio can be formed against zero, so
SMOKE_DISCRIMINATION_RATIOnever applied to the counter axes andlarge > smallwas the entire test. The CI configuration runs one repetition on Chromium, so there is no median to smooth a stray dropped frame, andharness_failuresaccepts any ONE discriminating axis: 0 missed frames at 25K and 1 at 100K could carry the whole liveness verdict while every latency axis was flat.ZERO_BASED_MIN_RISE(5) now applies whatever the baseline, so 1 to 2 frames no longer discriminates either.Which axes are counts is stated, not inferred. The zero branch keyed on whether a paint floor had been subtracted, which only identifies a floored timing. An unfloored timing such as
longest stall msreads zero whenever the action resolves before the recorder produces a sample, and was then judged as a dropped-frame counter, so 0 to 5 milliseconds read as a rise of 5 events.COUNTER_AXESnames the set; the floor is not applied to timings, which would silently reject real latency curves sitting at low absolute values.An application exception is not engine chatter.
console.errorand uncaughtpageerrorwent into the same list as Firefox's two scroll-anchoring notices and were tolerated by the same allowance, so a run could exit 0 with an exception inside a timed interaction. Severity is preserved now; the allowance applies to warnings only, and Gecko remains reportable.The report survives being written out. Making a floor a callable put the lambda into the growth report, and
main()json.dumpsit, so every complete run raisedObject of type function is not JSON serializableAFTER taking all its measurements. The report stores the resolved count at each end of the ratio - a boolean would serialise and say nothing.seedCompactTailandgapMetricsBoth are on the
window.__heavyThreadAPI under exactly those names, so #9058's gap probe can call them, andtests/studio/test_heavy_thread_gap_contract.pypins the method names, the argument shapes and every key of thegapMetricsreturn. That file is registered explicitly instudio-frontend-ci.ymland imports onlyreandpathlib, so it cannot silently skip.Proven red on a broken tree
93 CPU-only assertions. Every one was made to fail on a targeted broken tree before being kept: each floored axis back to zero, the checker short-circuited, a missing count skipped rather than reported, the checker unwired from
harness_failures, the counter removed from the recorder loop, the reset removed frombegin(), the noise floor skipped for nonzero baselines, the noise floor wrongly applied to timings, every counter treated as noise, timings judged as counters, the counter set emptied, console errors tolerated, the two severities merged, the callable put back into the report, and a boolean in place of the count.Controls are included deliberately, so no rule can be satisfied by rejecting everything: a clean cell must stay silent, a counter that really rose must still discriminate, a gesture a fraction short must still be accepted, and Gecko's two notices must still be tolerated.
Corrections made during review, stated
Four of my own assertions were wrong when first written:
ms, which is wrong: the axis is calledframes over 33msand legitimately does. That check fired against correct code, which is worse than no check. It names the set exactly now.Limits
PerformanceObserver.supportedEntryTypesis what decides, becauseobserve({type:"longtask"})is accepted and then never fires on WebKit and Firefox.