Studio: measure where a heavy thread stalls, across engines and thread size by danielhanchen · Pull Request #9016 · unslothai/unsloth · GitHub
Skip to content

Studio: measure where a heavy thread stalls, across engines and thread size - #9016

Merged
danielhanchen merged 39 commits into
mainfrom
perf-heavy-thread
Aug 18, 2026
Merged

Studio: measure where a heavy thread stalls, across engines and thread size#9016
danielhanchen merged 39 commits into
mainfrom
perf-heavy-thread

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Aug 16, 2026

Copy link
Copy Markdown
Member

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.py plus studio/frontend/smoke-heavy-thread.{html,tsx} mount the real Thread, 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 python tool call with a code-execution result pane, a code_execution tool call with a bash result pane, a full html document that Studio collapses into an artifact card, a render_html tool call (the HTML canvas artifact), an svg fence 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:

  • The HTML artifact renders as an artifact card in the thread, not as a live preview. The <iframe> lives in ArtifactSurface on 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.
  • python results carry images: []. 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:

  • longest stall ms: the largest gap between consecutive ticks of a 1ms setTimeout loop. The main thread cannot answer the timer while it is busy, so the gap is the block.
  • worst frame ms and frames over 33ms: gaps between consecutive requestAnimationFrame callbacks.
  • time to settle: from the start of the action until three consecutive frames come in under 33ms.

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:

  1. PerformanceObserver.observe({ type: "longtask" }) does not throw on WebKit 26.5 or Firefox 153. It is accepted and then never fires. A try/catch therefore 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 from PerformanceObserver.supportedEntryTypes, which lists longtask on Chromium alone.
  2. A MessageChannel ping-pong is the usual way to build a stall detector and it was rejected here. It spins about 150k times a second, and on Firefox that halved the frame rate of the page under test (38 frames to 14, median frame 17ms to 34ms) before any Studio code ran. The 1ms setTimeout loop 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.
  3. Waiting for "the highlighted token count stopped moving" over two consecutive polls is not enough. Two adjacent rAF-polled reads land inside the lull between two async Shiki batches. On WebKit that released the gate at 577 highlighted tokens where the finished cycle has 3216, and the whole engine column was measured against a thread still building itself. The gate is now five stable reads a quarter of a second apart, and the fixture census has a floor under the token count.

Engines

  • chromium is the proxy for WebView2, that is Desktop on Windows.
  • webkit is the proxy for WKWebView and WebKitGTK, that is Desktop on macOS and Linux.
  • firefox is 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.rs sets WEBKIT_DMABUF_RENDERER_FORCE_SHM=1 on 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.getMetrics and exists on no other engine.

# Action 25K to 300K, Chromium Where the time goes at 300K Same rise on WebKit / Firefox
1 Message action menu, open + close 324ms to 3367ms, 10.4x 2804ms of 3367ms is style RECALCULATION, in only 10 recalcs and 2 layouts. document.body goes to pointer-events: none while the menu is open, so the Radix modal layer invalidates style for the whole thread, twice, for a three-item popover 9.9x / 7.0x
2 Leave the thread and re-open it 444ms to 3175ms, 7.1x 2817ms of script, 748ms style, 87ms layout over 59 recalcs and 19 layouts. Unmounting takes 85ms; the other 3.1s is rebuilding every markdown block, every Shiki fence and every action bar from nothing. There is no incremental path 9.6x / 9.8x
3 Delete a message 107ms to 505ms, 4.7x 503ms of the 505ms is script, with 1.9ms of style and 1.3ms of layout. This is the thread.export() to repository to thread.import() round trip, and it is O(messages) wherever the target sits 6.1x / 5.6x
4 Keystroke in the composer longest stall 32ms to 157ms, 4.9x 249ms of task time, worst long task 136ms. The character itself still paints on the very next frame at every size. What grows is the work that follows it, which is what makes sustained typing feel laggy rather than what makes one keypress feel slow 4.1x / 6.7x
5 Scroll gesture, 20 steps of 400px frames over 33ms 0 to 9; worst frame 5.4x; wall clock only 1.4x 616ms of task time against 156ms at 25K. The layout is already done at mount, because studio/frontend/src/index.css forces content-visibility: visible !important on 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 gesture 2.5x / 3.6x on stall
6 Jump from the bottom of the thread to the top longest stall 12ms to 47ms, 4.0x Cheap in absolute terms, 77ms wall at 300K 1.0x (flat) / 7.1x

The 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)

Action chromium 25K chromium 100K chromium 300K webkit 25K webkit 100K webkit 300K firefox 25K firefox 100K firefox 300K
Keystroke in the composer 32 66 157 23 37 95 26 70 174
Scroll gesture (20 x 400px) 20 42 111 20 27 51 25 55 91
Jump to the top of the thread 12 33 47 8 9 8 10 37 71
Message menu open + close 163 567 1604 182 768 2128 112 334 772
Delete a message 94 185 498 37 135 5 72 5 9
Leave the thread and re-open it 387 1174 3052 260 974 2888 280 1135 3133

Wall clock for the whole action, ms (median of 3)

Action chromium 25K chromium 100K chromium 300K webkit 25K webkit 100K webkit 300K firefox 25K firefox 100K firefox 300K
Keystroke in the composer 326 354 427 372 374 400 319 374 450
Scroll gesture (20 x 400px) 717 717 984 692 693 742 717 784 833
Jump to the top of the thread 80 72 77 81 80 80 77 106 136
Message menu open + close 324 1158 3367 363 1356 3584 197 562 1385
Delete a message 106 228 505 59 159 362 105 257 583
Leave the thread and re-open it 444 1235 3175 349 1218 3357 388 1420 3813

Ratio, smallest size to largest

Action metric chromium webkit firefox
Keystroke in the composer longest stall 4.9x 4.1x 6.7x
Keystroke in the composer wall clock 1.3x 1.1x 1.4x
Keystroke in the composer worst frame 4.9x 3.0x 6.8x
Keystroke in the composer frames over 33ms 0 to 1 0 to 1 0 to 1
Scroll gesture (20 x 400px) longest stall 5.6x 2.5x 3.6x
Scroll gesture (20 x 400px) wall clock 1.4x 1.1x 1.2x
Scroll gesture (20 x 400px) worst frame 5.4x 3.0x 4.4x
Scroll gesture (20 x 400px) frames over 33ms 0 to 9 0 to 2 0 to 2
Jump to the top of the thread longest stall 4.0x 1.0x 7.1x
Jump to the top of the thread wall clock 1.0x 1.0x 1.8x
Jump to the top of the thread worst frame 2.8x 1.0x 4.3x
Jump to the top of the thread frames over 33ms 0 to 1 0 to 0 0 to 1
Message menu open + close longest stall 9.8x 11.7x 6.9x
Message menu open + close wall clock 10.4x 9.9x 7.0x
Message menu open + close worst frame 10.0x 11.7x 7.0x
Message menu open + close frames over 33ms 2.0x 1.0x 1.5x
Delete a message longest stall 5.3x 0.1x 0.1x
Delete a message wall clock 4.7x 6.1x 5.6x
Delete a message worst frame 4.5x 7.9x 6.7x
Delete a message frames over 33ms 2.0x 1.0x 1.0x
Leave the thread and re-open it longest stall 7.9x 11.1x 11.2x
Leave the thread and re-open it wall clock 7.1x 9.6x 9.8x
Leave the thread and re-open it worst frame 8.3x 11.5x 11.3x
Leave the thread and re-open it frames over 33ms 2.0x 2.0x 1.8x

Fixture census, identical on all three engines

25K 100K 300K
characters built 27,873 112,710 310,603
content cycles 1 4 11
messages 20 80 220
DOM nodes 4,048 15,887 43,422
code blocks 7 28 77
highlighted tokens 3,216 12,804 35,086
tool cards 2 8 22
open tool result panes 2 8 22
artifact cards 2 8 22
images 3 12 33
action bars 10 40 110
viewport scrollHeight 11,484px 45,297px 124,241px

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

  • longest stall, for keystroke, scroll, jump, menu and re-open. 2.5x to 11.7x.
  • worst frame, for every action except the jump on WebKit. 2.8x to 11.5x.
  • wall clock, for menu, delete and re-open. 4.7x to 10.4x.
  • frames over 33ms, from 0 for keystroke, scroll and jump; 1.8x to 2.0x for menu, delete and re-open.
  • time to settle for the re-open: 444ms to 3175ms.

Did not discriminate, with the reason in each case

  • Keystroke to paint, the median time from the input event to the frame that shows the character, is 33.3ms at every size on Chromium and Firefox, which is exactly the two-vsync paint floor the harness measures per cell. It discriminated on WebKit alone (2ms to 16ms above the floor). The composer paints the character on the very next frame no matter how long the thread is. Reported rather than dropped, because the answer is the finding: the keystroke is not slow, the thread's reaction to it is, and the stall metric on the same action does discriminate at 4.1x to 6.7x.
  • Longest stall for the delete rose 5.3x on Chromium locally and went the wrong way on Firefox (72ms to 9ms), while the worst frame for the same action rose cleanly on both (4.5x, 6.7x). Reading: the delete's work is split into tasks short enough that no single one is a long block, while no frame is painted for the duration. The stall metric measures the longest single blocking task and is blind to time-sliced work; the frame metric is not. Both are in the table for that reason, and the delete is ranked on wall clock rather than on stall.
  • Time to settle after the scroll gesture and after the jump tracks the wall clock of the action itself and adds nothing over it, because both go quiet within three frames of the input stopping. Kept in the table, not part of the ranking. It was fixed once already: measured from the end of the gesture instead of from the start of the action, it read about 50ms at every size on every engine, which is the three-frame minimum the loop can return and not a measurement of anything.
  • Jump to the top on WebKit is flat at 8ms to 9ms across the whole range, on every platform, against 4.3x to 7.7x on Chromium and 2.6x to 6.7x on Firefox. That is an engine difference rather than a harness failure, and it is consistent across three operating systems.

Cross-platform, on GitHub-hosted runners

Replicated onto danielhanchen/unsloth-staging-2 with pr_review/staging_ci.py and run on three runners, 2 repetitions per cell, one engine per invocation. Runner shapes as the jobs report them: ubuntu-latest 4 cpus, Intel Xeon Platinum 8573C, 17 GB, Linux 6.17; windows-latest 4 cpus, AMD EPYC 7763, 17 GB, Windows 10.0.26100; macos-14 3 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-arm64 is 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

Action ubuntu-latest
chromium
ubuntu-latest
webkit
ubuntu-latest
firefox
windows-latest
chromium
windows-latest
webkit
windows-latest
firefox
macos-14
chromium
macos-14
webkit
macos-14
firefox
Keystroke in the composer 173 136 203 327 128 330 368 - 151
Scroll gesture (20 x 400px) 82 64 104 106 69 180 194 - 104
Jump to the top of the thread 49 8 74 57 8 137 111 - 71
Message menu open + close 1849 2398 1127 2593 3838 2260 1776 - 909
Delete a message 257 7 5 277 562 8 4 - 462
Leave the thread and re-open it 4036 3316 4418 5832 4678 8911 4166 - 3006

Wall clock for the whole action, ms, at 300K characters

Action ubuntu-latest
chromium
ubuntu-latest
webkit
ubuntu-latest
firefox
windows-latest
chromium
windows-latest
webkit
windows-latest
firefox
macos-14
chromium
macos-14
webkit
macos-14
firefox
Keystroke in the composer 465 514 500 620 470 735 686 - 437
Scroll gesture (20 x 400px) 1000 770 1116 1266 747 1535 1392 - 938
Jump to the top of the thread 103 77 144 119 76 241 167 - 114
Message menu open + close 3822 4142 2051 5278 6366 2846 3669 - 1291
Delete a message 480 580 649 682 623 1041 522 - 560
Leave the thread and re-open it 4194 3802 5462 5998 5290 11772 4756 - 3644

Frames over 33ms, ms, at 300K characters

Action ubuntu-latest
chromium
ubuntu-latest
webkit
ubuntu-latest
firefox
windows-latest
chromium
windows-latest
webkit
windows-latest
firefox
macos-14
chromium
macos-14
webkit
macos-14
firefox
Keystroke in the composer 2 2 2 2 4 5 3 - 1
Scroll gesture (20 x 400px) 5 2 3 5 2 3 6 - 3
Jump to the top of the thread 0 0 0 0 0 0 0 - 0
Message menu open + close 4 2 3 4 2 2 4 - 2
Delete a message 2 1 1 2 2 1 1 - 2
Leave the thread and re-open it 4 4 10 4 6 33 4 - 12

Ratio, smallest size to largest, longest stall

Action ubuntu-latest
chromium
ubuntu-latest
webkit
ubuntu-latest
firefox
windows-latest
chromium
windows-latest
webkit
windows-latest
firefox
macos-14
chromium
macos-14
webkit
macos-14
firefox
Keystroke in the composer 5.1x 5.1x 6.1x 7.9x 3.9x 6.7x 5.2x - 3.4x
Scroll gesture (20 x 400px) 3.4x 2.7x 3.6x 3.7x 2.5x 4.4x 6.2x - 2.5x
Jump to the top of the thread 4.9x 1.0x 4.4x 4.3x 1.1x 6.7x 7.7x - 2.6x
Message menu open + close 8.8x 9.8x 7.5x 8.5x 10.7x 7.5x 11.2x - 7.5x
Delete a message 4.7x 0.3x 0.1x 3.8x 5.1x 0.1x 0.1x - 6.2x
Leave the thread and re-open it 7.7x 9.1x 9.2x 8.9x 10.1x 10.3x 11.2x - 7.7x

Repetitions and runner shape

  • ubuntu-latest: 2 repetitions per cell, chromium 151.0.7922.34, webkit 26.5, firefox 153.0
  • windows-latest: 2 repetitions per cell, chromium 151.0.7922.34, webkit 26.5, firefox 153.0
  • macos-14: 2 repetitions per cell, chromium 151.0.7922.34, firefox 153.0; NO COLUMN for webkit

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-latest runs 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-latest is 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 reproduces WEBKIT_DMABUF_RENDERER_FORCE_SHM=1, which studio/src-tauri/src/linux_webkit.rs sets 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 main plus 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.yml gains 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.py pins the rules the harness is held to, in the same spirit as test_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 ms declared 0. Reopening is a React state update, so the count check straight after openThread() always still sees the unmounted tree and the loop always pays at least one __nextPaint().
  • The generated wall ms axes declared 0 for every action. MENU_JS opens the recorder before opening the menu and closes it after closing it, so it crosses the same two waits menu open+close ms correctly declares.
  • scroll gesture ms, scroll settle ms and jump settle ms declared 0 while spanning the whole recorder window. quiet() and quietUntilIdle() return elapsed-since-startedAt, not their own duration, and gestureMs is computed from startedAt too, 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_problems compares declaration against observation for every engine and size and fails the run on a mismatch.

Not applied uniformly. jump painted ms starts at a mark taken after begin() and spans one wait while the jump's window holds two; 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. 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, and both halves have tests.

A counter rising from zero has to say something. No ratio can be formed against zero, so SMOKE_DISCRIMINATION_RATIO never applied to the counter axes and large > small was the entire test. The CI configuration runs one repetition on Chromium, so there is no median to smooth a stray dropped frame, and harness_failures accepts 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 ms reads 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_AXES names 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.error and uncaught pageerror went 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.dumps it, so every complete run raised Object of type function is not JSON serializable AFTER taking all its measurements. The report stores the resolved count at each end of the ratio - a boolean would serialise and say nothing.

seedCompactTail and gapMetrics

Both are on the window.__heavyThread API under exactly those names, so #9058's gap probe can call them, and tests/studio/test_heavy_thread_gap_contract.py pins the method names, the argument shapes and every key of the gapMetrics return. That file is registered explicitly in studio-frontend-ci.yml and imports only re and pathlib, 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 from begin(), 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:

  • The severity check pinned an exact one-line expression and stayed green when the predicate moved to its own line. It keys on the predicate alone now.
  • A counter-set assertion required the name not to end in ms, which is wrong: the axis is called frames over 33ms and legitimately does. That check fired against correct code, which is worse than no check. It names the set exactly now.
  • A break needle was mis-indented and never applied, so that assertion was briefly unproven rather than proven.
  • Three serialisation tests exist because every earlier test built a report and asserted on its fields without ever writing it out.

Limits

  • Long-task counts are Chromium-only and labelled as such in every table; PerformanceObserver.supportedEntryTypes is what decides, because observe({type:"longtask"}) is accepted and then never fires on WebKit and Firefox.
  • Playwright's WebKit is a proxy for WKWebView and WebKitGTK, not the webview Desktop embeds; Desktop Linux under Wayland is worse than any number here.
  • The harness asserts no budgets. It exits non-zero only when it is measuring nothing.

…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.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

danielhanchen and others added 2 commits August 16, 2026 16:14
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.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

…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.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

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>
chatgpt-codex-connector[bot]

This comment was marked as resolved.

pre-commit-ci Bot and others added 3 commits August 18, 2026 03:21
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.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

pre-commit-ci Bot and others added 3 commits August 18, 2026 10:39
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.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

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.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

danielhanchen and others added 2 commits August 18, 2026 11:11
… 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.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Aug 18, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

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
danielhanchen merged commit 58403dd into main Aug 18, 2026
33 of 36 checks passed
@danielhanchen
danielhanchen deleted the perf-heavy-thread branch August 18, 2026 11:33
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant