Skip to content

Release swapchain buffers on bg tabs - #147

Merged
danneu merged 29 commits into
masterfrom
research41
Sep 2, 2026
Merged

Release swapchain buffers on bg tabs#147
danneu merged 29 commits into
masterfrom
research41

Conversation

@danneu

@danneu danneu commented Sep 2, 2026

Copy link
Copy Markdown
Owner

No description provided.

series.md cites `scripts/research/41/ten-tab-footprint.py` as the
instrument behind row S1, so the script has to live in the repo with the
doc that reads its output.

It stages one optimized dev slot the way termwars' memory harness does
-- seeded font, ten inert tabs, calibrated geometry, every pane read
back at the benchmark grid -- then settles and samples `footprint` over
the slot's pid set, and prints one JSON document. Staging comes from
termwars' adapter rather than a second implementation, so a grid
read-back mismatch fails here the way it fails there.

This is the number to read while changing DanTerm, not a claim: one
slot, one arm, no interleaved control. A claim stays a paired harness
run recorded in the series.

The script stages gate and done files under `/.run` for a slot that is
already gone when it exits, so `.gitignore` covers that directory.

Claude-Session: https://claude.ai/code/session_01Qa8frwM3hrN87APj1EttDz
The tier-1 script grows a `--hold` flag: it prints its document with the
measured pids as soon as the samples are in, then keeps the slot up until
stdin gives a line or SIGINT arrives. That is what a per-class capture
needs -- the same staged process the samples came from, still alive.

`vmmap` and `footprint` on the held slot at this revision read 31
`IOSurface` regions holding 607,649,792 bytes, all dirty. Thirty of them
are `2720x1860` BGRA, one per buffer of ten depth-3 pane swapchains, and
ten of those are shared with the WindowServer. F2 predicted 607,104,000
by arithmetic; the 0.09% difference is 16 KB page rounding. The largest
class left is `MALLOC_SMALL` at 25 MB of a 36,603,176-byte remainder.

H1 is confirmed by allocation class. F4 records the capture, S2 records
the reading, and the ledger closes T2.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
Research 41 has to attribute the app's IOSurface footprint to the panes that
own it, and the objects that own the surfaces were the only ones that could
answer without a counter that can drift. A store now reports the kernel's own
allocated size for its surface -- the page-rounded figure a vmmap IOSurface
line sums (41/F4), not the tight bytesPerRow product -- and a swapchain builds
its census by asking each buffer it holds, so a chain that failed to build
every buffer it asked for cannot report the size it wanted.

`holds` goes with it: the owner of a replaced rotation still retains the frame
on screen, and it needs to tell a store the live rotation holds from one it has
outlived.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
Research 41 needs the app to say what its panes' buffers cost, beside the
process footprint it samples, and with zero distinguishable from unmeasured
(41/D1). The answer is derived when the query is answered: the runtime walks
the installed panes, each pane's view asks its own rotation, and nothing
increments or decrements anywhere, so the number cannot drift from what the
process holds.

A pane whose session has no presentation to measure is named in
`panes.unmeasured` and reads as `"swapchain":"unmeasured"` -- a string, distinct
from the `null` that means a pane holding no buffers -- and is left out of every
sum and of the visible/hidden split. Every aggregate carries its count.

The census also counts a displayed frame the live rotation no longer holds: a
replaced rotation leaves its predecessor's store retained until the successor
presents, and a walk of rotations alone would miss exactly that surface.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
…tion

The ten-tab document now carries a `surfaces` block, read with `danterm
surfaces` from the sampled process before the slot stops, so every tier-1
reading arrives with the app's own attribution beside its total. A read that
failed is recorded as unmeasured rather than as a zero.

`S3` is the first row whose attribution comes from the app: 607,518,720 bytes
over 30 stores in 10 chains, 9 of 10 panes hidden. That is exactly `F4`'s
30-region pane line; the 131,072 bytes between it and `F4`'s IOSurface class
total are that capture's one CoreUI image surface, which no pane owns.

`D4` records why the number is derived from the live panes and not counted, and
what the rejected registry would and would not have caught.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
Research 41's T3 needs what a tab switch costs before Phase 3 makes a
hidden pane's swapchain something reveal has to rebuild, and no instrument
here could answer it. The frame-rate log sums a second of publishes,
renders and layer displays; a reveal happens once, inside one of those
windows, against a frame arrival the window has already averaged away.

`DANTERM_PRESENTATION_EVENT_LOG` now names a file that receives one JSON
line per pane presentation moment -- the view's creation, each visibility
transition, and every frame that reaches the layer -- each with the
monotonic timestamp it happened at. A reader pairs a `reveal` with the
next `attach` on the same pane and subtracts, which is the whole latency
question in the app's own clock, with no screen capture and no
cross-process clock in the path.

Only a real transition is traced: a redundant push of the visibility a
pane already has writes nothing, so a switch with presentation work to do
stays distinguishable from one without. The line is written where it
happens rather than buffered, because a flush would otherwise land inside
the interval being measured.

`scripts/research/41/tab-switch-latency.py` drives the three cases on the
ten-tab staging and prints one JSON document. The launcher's passthrough
allowlist gains the variable; the script forwards it through a shim,
because termwars' adapter takes no launch variables of its own.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
Research 41's T3 found that revealing a hidden tab at this revision
presents nothing at all: the pane's last frame is still attached to its
layer, the pane is idle, and no frame follows the reveal. So the reveal
case has no latency to report, and the cost Phase 3 would add -- a fresh
depth-3 swapchain and a full render on every reveal -- had no number.

The trace gains a `rebuild` event, recorded when a pane gives up a
rotation it actually held, so the `attach` that follows can be paired with
it. That prices allocate-plus-full-render where the app already forces it
today, on a theme change, and `tab-switch-latency.py` reads it as a fourth
case beside the three switches a user can make.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
The control Phase 3 has to be judged against. Twelve reveals of nine
hidden tabs at `2c544f84` recorded twelve `reveal` events and zero frames:
a hidden pane's last frame never leaves its layer, the pane is idle, so
revealing it costs the reconcile and the visibility push and no pixels at
all. There is no reveal-to-frame latency here because there is no frame.

So the number that matters is what a reveal would cost once it has to
build buffers first, and F5 prices it where the app already forces that
work: 16.59 ms median (n=12) to allocate a fresh depth-3 swapchain and
render all 60 rows at 2720x1860, with a 43 ms tail. A cold first
presentation is 18.90 ms (n=12). Both round trips of the request are
dominated by spawning the CLI, and the warm reselect -- which can do no
reveal work by construction -- is the slower of the two, so nothing the
reveal does shows above that noise.

That makes the direction gate concrete. Visible-lifetime release has
nothing to show until it has rebuilt, so its reveal is the 16.59 ms case
at best; the frozen-surface and purgeable-volatile shapes can present
immediately and could beat it. D2 stays open, with one of its four
findings now in hand.

S4 records the footprint at the same commit in the same session, so the
row carries both halves of the trade.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
A throwaway build marks a hidden pane's surfaces purgeable-volatile after
detaching the layer contents, and marks them non-volatile and renders again on
reveal. Measured, then reverted; the diff is saved under readings/ so it can be
reapplied.

Both of T6's questions are answered. Volatile IOSurface pages leave
phys_footprint at once on this macOS, with no memory pressure: 645,039,424
bytes on the clean tree against 98,501,952 on the throwaway, taken in one
session, and vmmap reads 27 of the 30 pane surfaces PURGE=V with 0K dirty. The
render server had not released the surface by the time it went volatile: 44 of
44 hides marked one still-in-use buffer, after a committed and flushed
detaching transaction.

The reveal is the other half. It presents a real frame in 1.37 ms (n=12),
against F5's no frame at all, and against a 6.32 ms from-scratch rebuild in the
same run. So this shape beats visible-lifetime release on memory and on
latency at once, and beats one frozen surface per hidden pane on memory.

The seam it leaves is the in-use surface. A discard under real pressure would
hand undefined pixels to whatever composites the hidden window. The ideal shape
marks only the surfaces reported free; what that costs of the saving is
unmeasured, and it is what D2 still owes.

F8 records the API cited from the SDK headers, the readings table with its
control, the PURGE census, the isInUse counts, and the reveal latency. Series
rows S5 and S6. T6 -> VETTING.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
A new `TerminalFrameBackingStore` cleared its whole IOSurface, which made
every page of every buffer resident at creation. With ten idle tabs that
is 405 MB of memory holding pixels nothing ever displayed: each pane owns
a depth-3 swapchain and an idle pane renders into one of the three.

The clear guaranteed nothing. A full render fills the whole frame rect in
`.copy` blend mode, and the surface is the grid exactly, so it decides
every pixel. The incremental path runs only on a buffer that already holds
a complete frame, and a store reaches a layer only after a render. The
zeroing is inherited from the malloc-backed store, where it was what made
raw allocated memory initialized before CoreGraphics wrote it.

The gate that replaces it is behavioral: poison the surface memory, render
full, and compare every pixel against a direct render. It fails if a full
render ever stops covering the surface, which is the one thing dropping
the clear depends on.

The ten-tab empty arm falls from 645,301,568 to 240,764,008 bytes
(research/41 S5, S6). The saving is idle-only: a pane that renders three
frames takes 39 MB of it back (research/41 F7).

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
F7 records the pair: 645,301,568 bytes with the eager surface clear and
240,764,008 without it, one session, control first, same commit and same
staging (S5, S6). The 404,537,560-byte difference is the twenty buffers
ten idle panes never render into, to within 0.1%.

The finding also writes down what the clear guaranteed -- nothing any code
path asks for -- and proves the saving is idle-only, the way the
investigation rules require: three staged panes given twelve echoed lines
each took 117 MB of it back, 39 MB per pane, which is two surfaces each.

The surface census reads the same mapped bytes in both arms, which is what
D4 said would happen once residency and mapping diverge.

The ledger moves T5 to VETTING. It decides nothing about H1: the term
returns as soon as the tabs are used, so T7 still has to choose a
lifetime. F2's scratch note was never committed, so its citation carries
the documented allow-missing marker and docs-lint passes again.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
T5 and T6 ran in parallel worktrees and both claimed series rows S5/S6 and
the control reading's filename. T6 keeps S5/S6; T5's control and throwaway
become S7/S8, its control reading moves to a t5-control filename, and the
ledger keeps one VETTING entry per task.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
Hidden panes become detached and untrusted. Commit 1 of T8 is the ideal
(visible-lifetime release, no presentation while hidden); commit 2 adds the
purgeable-volatile fast path for buffers the render server reports free, so
a reveal with intact pages costs 1.37 ms instead of a rebuild.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
Hiding a pane detaches its frame in a committed and flushed transaction,
forgets the store that held it, and gives up the swapchain. While hidden the
pane presents nothing: the single render path returns at once, so a theme
swap, a font change, a backing-scale move, a window color-space change, a
published frame, and an armed presentation retry all leave the pane with no
buffers. The reveal reconciles the geometry, then renders exactly once -- and
only when nothing else put a frame back, so a pane with hidden-time output
pays one render, not two.

This is research/41 D2's ideal, visible-lifetime release, landed on its own.
At the ten-tab staging D2 measures, twenty-seven of thirty pane surfaces
belong to hidden panes, and this is the structure in which a hidden pane
cannot hold any of them. What it costs is the reveal, which becomes a
from-scratch rebuild; D2's second commit adds the volatile fast path and T9
reports both numbers.

The reveal defers its render out of synchronizePresentation for one reason:
a grid submitted on reveal republishes through the controller, and rendering
the old plan first would build a rotation that republish immediately
replaces -- two rebuilds for one tab switch.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
… commit 2)

The swapchain gains the two entry points that own purgeability. `releasePixels`
marks every buffer the render server reports free purgeable-volatile and says
how many it still holds; `reclaimPixels` takes the pages back and reports what
the kernel did with them. Between the two the rotation refuses to write any
buffer at all, so a released buffer cannot be rendered into whoever asks. Both
the in-use report and the purgeability call are injected the way the in-use
report already was, so the headless suite reaches every outcome -- including the
discarded one, which needs real memory pressure on a machine.

The hide now keeps the rotation and releases its pixels instead of throwing it
away, which is what makes a reveal 1.37 ms rather than a from-scratch rebuild
(research/41 F8). The buffer that was attached is still in use after a committed
and flushed detach -- 44 hides of 44 in F8 -- and neither IOSurface nor
QuartzCore declares a notification for the use count changing, so the pane arms
a bounded per-refresh retry that asks again. The retry is inert on teardown, on
a reveal, and on a rotation replaced while hidden. A reveal that finds any
buffer discarded or unrestorable replaces the rotation, which is T25's one
answer to a trust break.

A surface goes volatile only while it is detached and reported free, which is
the same premise the swapchain already writes under. Pin four in
IOSurfaceLayerContentsTests observes that premise on a real compositor: a hidden
layer's detached surface frees while a sibling presents, goes volatile while
free, is never re-acquired, and comes back with its pages and its bytes intact.

`danterm surfaces` now reports the buffers by purgeability with a
non-volatile-bytes aggregate. Mapped bytes do not move when a pane hides, so
without that field the census could not be reconciled to a footprint reading at
all.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
…h is a wash

The ten-tab idle baseline is 56,902,928 bytes with both commits and 56,935,312
with commit 1 alone, taken in one session on one machine: 91% below S4's
644,777,256, and the two arms 0.06% apart. A reveal presents a real frame in
4.76 ms and 4.61 ms respectively, where F5 measured no frame at all.

Two of D2's open uncertainties are answered. The render server does free a
hidden layer's ex-attached surface -- 1 of 3 buffers is still in use at every
hide, exactly as F8 measured, and the bounded retry finds it free after 1 to 3
ticks, so the residual is 0 surfaces rather than the 0 to 182 MB D2 allowed for.
And the volatile fast path bought no measurable latency over the ideal, because
T5 made the rebuild the ideal pays cost 4.6 ms rather than F5's 16.59 ms. D2
wrote down what that answer means -- dropping commit 2 leaves the ideal in the
tree -- and T9 is where it is decided, beside the tier-2 pair and the
memory_pressure discard reading neither commit could take.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
…h/41 T8, commit 2)"

This reverts commit 471e8c0.

The fast path did not earn its structure. Measured in one session against
commit 1 alone (research/41 F9), it took 32,384 bytes of a 56.9 MB idle
footprint -- 0.06%, inside the spread of either arm -- and a reveal cost
4.76 ms with the pages kept against 4.61 ms with the rotation rebuilt from
scratch. Its premise was F8's 4.6x advantage, and that premise had already
expired: F8 compared a volatile reveal against a 16.59 ms rebuild, and T5
had since removed the eager clear, so the rebuild the ideal actually pays in
this tree is 4.6 ms. There is no advantage left to buy.

So the price is all that remains: a purgeability state per buffer, a bounded
per-refresh retry, a three-way reclaim outcome, and five census fields on a
CLI contract. D2 pre-committed to this outcome in as many words -- if commit
2's state does not earn its latency, dropping it leaves the ideal in the
tree. Commit 1 is that ideal and it stays: a hidden pane owns no pixels, and
every trust break while hidden is answered by replacement on reveal.

Pin four in IOSurfaceLayerContentsTests keeps the half that is still load
bearing. That a surface detached from a layer which then presents nothing
still frees while a sibling presents is commit 1's residual term, not the
fast path's: it is why a hidden pane's last buffer leaves this process at
all. The purgeable half of the pin goes with the code it gated.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
F9 measured T8's two commits against each other in one session, and the second
one did not earn itself: 32,384 bytes of a 56.9 MB idle footprint, which is
0.06% and inside both spreads, and a reveal of 4.76 ms against the ideal's
4.61 ms, which is the same number. D5 records why the premise expired rather
than the measurement being wrong. F8 priced the fast path against a 16.59 ms
rebuild; T5 then removed the eager surface clear, so the rebuild the ideal
actually pays in this tree costs 4.6 ms and there is no advantage left to buy.
D2 pre-committed to exactly this: if commit 2's state does not earn its
latency, dropping it leaves the ideal in the tree.

D5 also writes down what was given up -- the discard path stays unmeasured, and
the ideal's price is a rebuild that a slower machine or a much larger grid could
make expensive again -- and what would reopen it: a reveal-to-frame median above
8 ms on the tier-1 staging. That is about one refresh at 120 Hz, so it is where
a person can see the delay, and it is twice what is recoverable today, since
F8's 1.37 ms floor leaves at most 3.3 ms in F9's 4.6 ms median.

D2 keeps its text as the reasoning at the gate, with a line at the top pointing
here. F9 keeps its numbers with a line saying they were acted on. S9 and S10
keep their rows; the commit-2 row names the revert. T9 now has one arm to read
instead of two.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
…dule name

termwars' DanTerm adapter now resolves its slot launcher per instance, so one
run can build two checkouts. Assigning the old module constant stopped doing
anything, and this script's trace never reached the slot: every case came back
`unmeasured` with zero events. The instrument said so rather than reporting a
number, which is why it was caught, but the reading was lost.

The adapter exposes `launcher` publicly for this substitution, so the shim is
set on the adapter the script staged.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
…floor

One harness run built four checkouts and interleaved their reps, so the pair,
its A/A control and an intermediate revision share a session, a display, a
scale, a font and a grid by construction. That is what the doc's own rule on
contemporaneous comparison asks for, and it is the first time this doc has had
it.

`T2b` gives the series its noise floor: the same commit from two checkouts
differs by 81,896 bytes (0.145%) on the empty arm and 999,424 (0.366%) on the
scrollback arm.

`T9` reads the claim against that floor. The empty arm goes 644,089,152 to
56,525,712 (-91.22%) and the scrollback arm 821,396,896 to 272,974,880
(-66.77%) -- 7,175 and 549 times the floor. `T5` is 62% of the empty-arm delta
and `T8` commit 1 is 38%. A reveal presents a real frame in 5.39 ms where it
presented none at all, which is under `D5`'s 8 ms reopening bar, so the fast
path stays out.

Two method results ride along. Tier 1 and tier 2 agree to within 0.99% at the
same commit, as `D3` asserts. And the tier-1 script reading one build twice in
one session differs by 3.2x the tier-2 A/A floor, which is why `D3` puts a
claim at tier 2.

All 24 trials report one pid and an empty `missingPids` on every sample, and
the 56 samples inside a trial are byte-identical, so a row's spread is the span
of its three rep medians.

The doc stays open: `T4`'s scrollback census and `T10`'s remainder are
untouched, and the discard path went with the code `D5` reverted.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
… 57.7 MB

`H1`'s 607 MB term is gone, so the remainder is most of the footprint. `T10`
reads it on a held slot at `0dc62749` with `vmmap`, `footprint`, and `heap`,
and it is not the undifferentiated mix `H4` predicted.

`MALLOC_SMALL` is 25 MB (45.4%) and `IOSurface` is 20,381,696 bytes (35.3%).
Nothing else in the process reaches 4.6%. The surface class is the visible
pane's three stores: 60,751,872 mapped and one resident, the 20,250,624-byte
buffer the render server is displaying. The other two read `0K`, which is `T5`'s
removed clear at the region level.

`heap` splits the malloc term: 18,057,352 live bytes in 105,478 nodes over six
zones, plus 33% zone fragmentation. No class is over 6.4%. The largest per-pane
class is the grid's cell storage, 368,640 bytes per pane, and all ten panes'
terminal state together is 18% of what the one displayed buffer costs.

So `H4` is rejected as written and its reading survives for a different reason:
both dominant classes are irreducible. `H5` says the resident surface is the
floor of showing a pane, `H6` says the heap is a long tail with no owner worth
a plan, and `H7` says a third of `MALLOC_SMALL` is allocator slack. `T12`,
`T13`, and `T14` are their gates.

Docs and readings only. Finding `F12`, row `S21`.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
…6 MB per pane

Doc 15's census probe is headless only: no `danterm` query reads
`Terminal.memoryCensus` from a live pane. So the census ran on the termwars
scrollback arm's own feed reproduced at 170x60 -- 10,000 lines, each exactly
170 columns -- and the process reading was taken beside it on a held slot with
its own empty-arm control at the same commit and session.

A pane holding that history charges 13,855,944 bytes: 13,715,424 of arena, a
140,496-byte index at 1.01%, 24 bytes of side table, and no style table at all.
That is 82.59% of the 16 MiB budget, 8.116 arena bytes per retained cell against
a 16-byte live stride, and no heap allocation per retained row. Both terms H3's
competing explanation named are absent, so H3 is confirmed and the doc leaves
the line alone.

The census also corrects the headline. Ten of those panes plus the allocator's
rounding on them are 147,556,320 bytes, 63% of the 233 MB arm delta this
session measured and 68% of F10's tier-2 216,449,168. The rest is 2.16 MB per
pane of app-side heap, 1.78 MB per pane of allocator slack, the visible pane's
two IOSurfaces faulting in (40.6 MB, which is F10's 39 MB read by class), and
5.5 MB of CoreServices only the writing arm has. The class sum closes to 0.43%.

F11 is the finding; the ledger pointed T4 at F6, which was never written and
cannot be now, so the pointer moves. S21 and S22 are the rows, and T11 opens on
the app-side term with a falsification gate: give `danterm` a per-pane census so
the residual becomes a measurement.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
T4 and T10 ran in parallel worktrees and both claimed series row S21. T10
keeps S21; T4's scrollback reading and its control become S22 and S23, the
ledger reads T10, T11, T12-T14 in order, and the Outcome carries both the
remainder census and the scrollback verdict with one open-items list.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
`danterm surfaces` is an instrument, not a control: it reports the app's
internal state for an investigation, and nothing that drives DanTerm needs
it. A bare top-level verb put it next to `ls`, `focus`, and `roster`, which
callers do drive the app with.

The new `debug` verb holds diagnostic reads. `danterm surfaces` becomes
`danterm debug surfaces`, and the wire method, request case, and runtime
command follow it: `debug.surfaces` / `.debugSurfaces` / `.readDebugSurfaces`.
Nothing outside this branch called the old spelling, so it is gone rather
than aliased. T11's per-pane terminal census will join the namespace as
`debug memory`.

The catalog entry sits beside `doctor` in help order, with the other
diagnostics. The research/41 scripts and prose, and the CLI skill, name the
shipped spelling; the raw readings keep the spelling they were taken with.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
… labels fixed

H5 is closed rather than left open. T4's own capture answers it: the
scrollback arm reads 58.1M dirty over 5 IOSurface regions against the empty
arm's 19.4M at the same commit in the same session, and the region listing
names all three of the visible pane's stores fully dirty. So the resident
term is one buffer at idle and the swapchain depth under output, and H5 is
rejected as written.

D6 and D7 record two decisions the doc had made but never wrote down: the
eager surface clear comes out (on F7), and the scrollback line is at its
budget floor and this doc leaves it (on F11 and H3). Both carry the
alternatives their finding weighed and a reopening condition. T5, T4, F7 and
F11 point at them.

The rest is correction. F11 cited S23 where the scrollback row is S22. T5
and T6 were ticked but statused VETTING; both are done. T0 still owed two
consecutive HEAD runs that S19 and S20 took. F5's table said "four cases"
over five rows, so the cases are numbered. The index row named T4 as the open
work when T11 to T14 are. Shares derived from footprint and vmmap figures are
now binary throughout, and the README states that convention once.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
…1 review)

`T8` made a hidden pane detach its layer contents and discard its swapchain.
The hide was fed by `effectivePaneVisibility(in:windowVisible:)`, so "hidden"
meant either the model's own visibility or an occluded window. Mission Control
and App Expose composite an occluded window's live layer tree, so a covered
DanTerm would have shown bare background in every pane.

Split the two inputs at the seam where they already exist. Pane visibility now
reads the model alone and is the one fact that decides pixel ownership. Window
occlusion joins system sleep on the rendering gate: `AppRuntime` combines them
into `renderingAvailable` and pushes that through `setRenderingAvailable`, which
suspends planning and presentation and touches no buffer. The memory win is
unchanged, because the nine unselected tabs of a ten-tab session are hidden by
the model either way.

Also drop an inverted pin in `IOSurfaceLayerContentsTests.swift`. It asserted
the render server still held a detached surface right after the hide's own
flush, which froze one machine's timing as a requirement and would fail on
hardware that releases sooner. The frame-bounded loop below it is the real gate.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
…h scripts fail loudly (research/41 review)

The `debug surfaces` output contract promised `visible` and
`displayedOutsideSwapchainBytes` on every `perPane` entry, but an unmeasured
pane carries only `paneId` and `swapchain`. The contract and the SKILL.md prose
now name that narrower entry. A census test pins the third shape: a pane that
answered and holds no buffers, whose `swapchain` is `null`.

The two research scripts now create `.run` before `mkdtemp` uses it. The
footprint script catches only the transport and decoding failures around the
census read, so a bug in the script fails the document instead of hiding as
`unmeasured`, and it nests the app's reply as `surfaces: {status, census}`
rather than writing a `status` key into it. The latency script raises when the
presentation trace recorded no staging events, so an instrument that never armed
cannot report every sample as a missing frame; it also refuses `--tabs 1`, which
had no hidden tab to reveal and spun forever, and its `first_pair` walks past an
opening event with no frame instead of giving up on the whole drain.

Drops `bareSurfacesVerbIsNoLongerACommand`: the bare `danterm surfaces` verb
never shipped, so there is no old spelling to guard.

Claude-Session: https://claude.ai/code/session_01WdNVVvMvyGgeyCGwzfsubi
@danneu
danneu merged commit be9fdeb into master Sep 2, 2026
4 checks passed
@danneu
danneu deleted the research41 branch September 2, 2026 05:48
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