`wait_below_high_water` takes its mutex through `Locked::locked` and then
unwrapped the condvar wait on the next line. `Condvar::wait_timeout` hands
back the same poison the lock does, so the guard and the hole in it sat four
lines apart: a thread that panicked holding that mutex would stop the PTY
reader parking on it, which is the thread that pumps a pane's output.
The drift guard that was supposed to prevent exactly this only looked for
`.lock().unwrap()`, so it never saw the wait. It now covers both ways poison
reaches a caller, and names this site when the fix is reverted.
`host/server.rs` already did it the tolerant way and `control.rs` reads a
poisoned wait as "not done", so this was the one site out of step.
The docs site's reference was corrected when `--orphans` learned to spare
panes a client is attached to. The skill that ships with the product was not,
and it is the worse of the two to have wrong: an agent reads it before it runs
anything, and it still said `--orphans` "closes every pane no workspace holds"
and "closes exactly what `pane ls --all` marks orphaned".
The reference is drift-guarded against the CLI; the skill is guarded against
nothing, which is exactly why it drifted. Both are now held to stating the
attachment half of the test — to the rule, not to a sentence, so a reword that
drops it from either file fails at the moment someone can still check the
other.
Half a change, found by reading the docs rather than the code. `pane ls --all`
learned to count strays by whether anybody is attached; the per-row `orphan`
flag beside that count did not. The same JSON object contradicted itself, and
the reference says `--orphans` "closes what `pane ls --all` lists as orphaned"
— which had stopped being true for exactly the rows that matter, the panes a
window is adopting during a restore.
A script filtering `.panes[] | select(.orphan)` would have gone after panes
the reaper deliberately spares.
The row, the count, the doctor's row and the reaper now make one test, held by
a test that walks all four. The reference states both halves of what an orphan
is, since one of them is the half that keeps the command from taking a session
that is still coming up.
The Known section described a leak that has since been fixed, which makes it
worse than no entry: it tells a reader to expect a bug that is not there and
to reach for a recovery command they do not need. It is replaced by the fix.
Six more user-visible changes had landed without an entry — the reaper that
could take a live session mid-restore, the three ways out of the app that
discarded unwritten editor buffers, a saved file losing the line endings it
came with, a delete prompt implying a trash that does not exist, agent hooks
broken by a `$` in the install path, and one pane's panic costing every pane
on the machine.
Also corrects an issue reference I had wrong in both the changelog and a
comment: #672 is the rebuild that deleted tabs off the machine, not the
tab-close prompt for unsaved edits, which cites nothing.
Two rules of the authority's `pane_close` were unverified, and both are the
server-side twin of a gap the mirror had.
`active_tab` is an id, so one naming a tab that has just been emptied points
at nothing — and since the move never went out as a delta, every client keeps
its own stale answer too. Deleting the healing passed the suite.
`collect_orphan_panes` reads `m.panes`, so a record left behind after its tab
closed answers "orphan" on every later call, for ever, because nothing else
removes it. It persists as well: `machine.json` gains a row per closed pane,
and `pane ls --all` reads them back as panes no workspace holds. Deleting the
prune passed the suite too.
Both are held now, at the last pane of a tab and at a plain split close.
Checked by re-running the two mutations that had survived.
The mirror had no test for `RatioChanged` at all. Writing a fixed 0.5 instead
of the ratio the delta carries passed the suite, and so did answering `true`
for a path that names no split.
The second is the worse half. That return value is how `apply_delta` tells
its caller the mirror is still in step with the machine; a `true` it has not
earned is a divergence nobody notices, and the re-pull that would have
repaired it never happens.
Pinned against a tree with a split inside a split, so "the right node" is a
claim the test can actually make: the inner ratio moves and the outer one
does not, an empty path is the root, and a path ending on a leaf, a path
running off the end, and an unknown tab each write nothing and say so.
The mirror exists to be identical to the machine's tree, and `tree_sync`
diffs a window against it to decide what to push back — so order is part of
that identity, not a presentation detail. A mirror that agrees on which tabs
exist but not on their sequence makes the next diff propose moves nobody
asked for.
Three of its rules turned out to be unverified. `TabCreated` could ignore its
index and append, `TabMoved` could land a slot late, and `TabClosed` could
leave `active_tab` naming a tab it had just removed — each passed the suite
untouched. The last is the worst of the three: `active_tab` is an id, so a
stale one points at nothing, and emptying the workspace is the one case with
no other tab to fall back to.
Clamping past the end and the two not-found paths are pinned along with them,
since those are the arms that decide between doing nothing and panicking.
The card's orphan list and the CLI reaper answer the same question for the
same user, and after the reaper learned to skip panes a client is watching
they would have answered it differently — the card offering to close panes
the command leaves alone.
`attached` is not redundant with either of the card's existing tests. The
tree holds nothing for a pane a window has spawned and not yet filed, and the
window's own answer counts a slot that is still connecting, which the daemon
has nobody attached to yet. Three questions, none of which subsumes the
others, and the list is what is left after all three.
The row reads "N running that no workspace holds — `tty7 pane close
--orphans` ends them", and it was counting a different set from the one that
command takes: every pane a window is adopting during a restore counted here,
and the reaper now correctly ends none of them. A row that says seven beside
a command that ends nothing is a worse answer than either alone.
All three surfaces that tell the user about this set — the reaper, the
listing's count, the doctor's row — now spend one predicate, so the next
change to what counts as stray cannot land on two of them. Deleting the
attachment half of it fails a test on each.
The reaper's test was "the registry is running it and no workspace holds
it". A window restoring a layout spawns each pane, attaches to it, and only
then files it into the tree — so for a moment every pane it is adopting
answers to that description.
Not theoretical. Polling `pane ls --all` through a cold start of a seven-tab
window reported one, then two, then three, then six, then seven live panes as
held by no workspace, and the restore afterwards was correct: all seven were
wanted. `--orphans` at any point in that window takes the whole session, and
a script that reaps on a timer will eventually sit in it.
Attachment is the missing half, and it is the daemon's own fact rather than a
guess about timing: `attach` takes the pane's seat and the connection closing
calls `detach`, which clears it. A window adopting a pane is attached to it.
A pane whose layout was thrown away had its view dropped, which closed the
connection, which emptied the seat. Nothing else in the registry tells the
two apart, which is why the GUI had to ask its windows and the CLI could not.
`pane ls --all` counts the same set, because the line it prints tells the
reader to run the reaper.
Same race after the change: zero false positives across 300 polls, restore
intact. And a stray with nobody attached is still reaped — checked by
deleting the new filter, which fails both tests.
The switcher's orphan section asked one question — is this pane alive, and
does the local machine mirror hold it — and put everything else under a
button that hangs it up.
The mirror is the wrong sole authority for that, and `tree_sync` says why in
as many words: a pane a window has spawned and registered but not yet filed
is held by nothing in the tree at that instant, and a client is left out of
the deltas its own operations raise (#612). `hang_up_detached` and
`sweep_parked` both refuse to end a pane any window is showing, whatever the
tree says, because a leaked shell is recoverable and a shell killed under a
live window is not. This list did the same thing to the same panes and never
asked.
So it asks now. A window that will not answer subtracts nothing, which keeps
the list at worst what it has always been — it is the recovery tool for the
case where things have already gone wrong, so it must not empty itself when
a window is busy. And it can only ever shrink: a genuine stray is shown by
no window, so nothing that belongs on the list leaves it.
Three places spend `side_panel_max` on `clamp(own_floor, cap)` — the sidebar
and right-panel widths and both drag handlers — and `f32::clamp` panics when
its low bound is above its high one. A cap below the floor is therefore not a
layout glitch but the render path going down, on the frame a window happens
to get narrow enough.
The existing tests pin the answer at three widths. This pins the property
across every floor the callers pass and the degenerate widths a window
reports while it is being made or taken apart — zero, negative, infinite,
NaN — and makes the same clamp the callers make, so the test fails the way
they would.
Dropping the `.max(own_floor)` that guarantees it reports a cap of -360
against a floor of 180.
No behaviour change; the guarantee was already there and already relied on.
The last path in the family. An update relaunch is a quit with a restart
attached, and a code panel's buffers survive it no better than they survive
⌘Q — the layout comes back, the text does not.
The question has to be asked before `pending.launch()`, not at the `cx.quit()`
after it: past the launch the updater is already waiting on this process to
exit, so refusing there would hang the update rather than protect anything.
`core::update` knows about downloads and signatures and deliberately not
about windows, and the question needs a window to be asked in. So it exposes
a hook and the UI installs one, handing the launch itself over as the
continuation rather than returning a verdict, which is what lets the answer
arrive late the way every other prompt here does.
The hook is optional so a mistake in it can only fail to ask, never fail to
update — which also means an uninstalled guard looks exactly like a working
one until a relaunch quietly takes somebody's work, so there is a test that
startup claims it.
The window close learned this one commit ago; quit never knew it. `Quit` was
`cx.on_action(|_, cx| cx.quit())` and nothing more — the shortest path in the
product to losing text that cannot be got back, and the one most likely to
be pressed out of habit.
`on_app_quit` cannot be the guard: it hands back a future and gpui does not
let it refuse, so all it can do is save the session on the way out, which is
what it already does. The decision has to sit at the action.
Every window is asked, not the frontmost one, because quitting takes them
all — and the prompt is raised in front of the window the buffer is actually
in, with its tab brought forward, so the question is about something the
user can see. Answering it quits everything, which is what was asked for.
Only unwritten buffers stop it. The shells survive a quit-to-tray and belong
to the daemon anyway, so warning about those would be warning about nothing
— the same line drawn for the window close.
Verified the search really does walk past the first window: restricting it
to one window fails the test with the buffer in the second.
Closing a tab has asked since #672, and the reason given there was that
unsaved text is the one loss in this product that cannot be undone by doing
the thing again. Closing the *window* asked nothing.
It reads like the safe exit, and for everything else it is: the shells are
the daemon's and keep running, which is exactly why closing a window is the
keep-everything exit rather than a quit. But the code panel's buffers are
the window's alone, no session file carries them — the session records the
layout, not the text — so the window closing is the last moment they exist.
Every other guard in the app was pointed at the recoverable losses and this
one at nothing.
`on_window_should_close` must answer now and a prompt answers later, so the
first close is refused and the real one is made from the answer; a flag stops
that second close asking again, which would mean a window that never shuts.
Only unwritten buffers are asked about — a busy command and a live SSH link
survive the window and warning about them would be warning about nothing.
Three tests: the decision (which names the tab holding the buffer, not
whichever is in front), the flag, and the callback gpui actually calls,
driven through `simulate_close` — a guard wired to nothing is no guard, and
one wired wrongly is a window that will not close.
`scm_discard_all` asks once and then hands its second operation on as
`ScmFollowUp::Op`, which `scm_follow_up` runs straight through `run_git_op`.
That is the only place in the SCM panel where something destructive runs
with no confirmation of its own, and it is sound only while the follow-up is
something the single prompt actually described.
The existing test pins one working tree in detail. The property that keeps
the bypass safe is the other one — that nothing else can ever come out of
`discard_all_ops`, whatever the repository looks like — so this walks the
shapes a working tree is actually found in and holds every operation to the
two the prompt names.
Checked by adding a hard reset for staged changes: the test names it and the
reason it matters, which is that the approval given for discarding would
have carried it.
There are two, in two crates, and they are not redundant: one puts a path in
front of the user's own shell and has three dialects to pick between, the
other writes a line that some `sh` on a machine will re-read. A reader who
finds one and not the other has every reason to write a third.
Every agent takes the hook as a command *line*, so a shell parses it again
before anything runs. The path went in double-quoted, and inside double
quotes `sh` still expands `$`, a backtick and a backslash — so an install
under `/opt/build$stage` reached the shell as `/opt/build/tty7`. The hook
then never fired: no error, no log line, just an agent that quietly stopped
reporting its status for good.
Checked against a real `sh` first, because the failure is invisible from the
code: the double-quoted form resolves the program to `.../q/tool`, the
single-quoted one to `.../q$stage/tool`. Then checked again through the
generated OpenCode plugin under node with a stubbed `$`, which is the path
that actually ships — the assembled command re-parses to the full path,
dollar segment intact.
Which shell is the target's, not ours: a hook installed on a remote machine
runs there. Only a local Windows target keeps the double-quoted form, which
is what `cmd.exe` wants and what the PATH-resolvable case avoids needing.
`shell_quote` had been written out twice already, in `daemon::install` and
`daemon::shell_integration`. Rather than add a third copy this moves the one
implementation to `core::shells`, where a core module may reach it without
depending on the daemon, and both old homes now re-export it.
Deleting from the file tree calls `LocalHost::remove` — `remove_file` and
`remove_dir_all`. The file is gone. The confirmation said only that it "will
be deleted", which on a Mac or in Explorer reads as "moved to the trash",
because in every file manager the user has ever used it is.
The remote prompts made it worse rather than better. They have always said
"there is no trash on the far side", and read next to a local prompt that
says nothing, that plainly implies the local side has one.
So the local prompts now say what the remote ones do, in all three locales,
and a test holds every delete confirmation to naming the trash it is not
using. That sentence is only read on the day it matters, so nobody would
notice it going missing again.
A preset rebinds only the actions it names, so every default it leaves alone
stays where it was — which is where a collision would come from, and it would
only show up for the people who chose that preset. It is clean today.
Two actions on one key is not always wrong: `secondary-enter` is fullscreen
in the window and commit inside the commit box, which is what a scope is for.
It is wrong when the scopes match, because then one of the two simply never
fires, and nothing on screen says which — the winner is whichever
`rebuild_keymap` installed last, which is an ordering this file has already
had a bug about.
Nothing checked it. The 112 defaults are clean today; the test says so, and
says which pair if that stops being true. Actions with no default key are
exempt — an empty string is "palette only", not a claim on a key, and about
sixty actions are deliberately in that state.
`destructive()` decides what the confirmation dialog tells the user they are
about to lose, and the choice is load-bearing: a hard reset onto an older
commit clobbers the worktree *and* drops commits, and the code deliberately
reports the worse of the two, because telling someone only about their edits
invites a yes to a different question than the one being asked.
The test asserted only that such an op warns about something. Downgrading a
hard reset from LosesCommits to LosesWorktreeEdits passed it unchanged —
checked, not assumed. Each operation is now paired with the warning it owes
the user, and the same downgrade fails.
No behaviour change; the classifier was already right.
The daemon holds every shell on the box behind mutexes — the pty master, the
child handle, the writer, the pane state. Seventy-four of those locks were
taken with `.lock().unwrap()`, so a panic in any one critical section
poisoned the mutex and every later taker died on it too. One bug in one
pane's thread, and the daemon can no longer serve any of them.
Poisoning buys nothing here. Whatever inconsistency the panic left is there
either way; the flag only decides whether the next thread also dies. A
garbled write to one pane is recoverable, and losing every session on the
machine is not.
Most of the code already agreed — about seventy places carried on, spelled
out by hand — so this is mostly a drift fix. `Locked::locked` gives the
policy one name, the daemon uses it throughout, and a test walks
`daemon/` and fails with the file and line if a panicking lock comes back.
Somewhere that genuinely cannot tolerate the inconsistency can still take
the poison with `lock()`; the point is that it be a decision rather than
the default.
Closing a tab straight after making it stranded a live shell — six times out
of six, reproducibly. It holds a pty and an fd and runs until the machine
does not. A person cannot type that fast; an agent loop that opens a tab per
task and closes it when the task is done meets it on nearly every iteration,
which is the shape this product is built for.
The rule for judging a pane was already here and already right: one that no
window is showing and no workspace on the machine names is nobody's. Two
earlier attempts failed because of what was put in front of it. Both parked
panes at the *site* that dropped them, and the leak does not live at a site:
a hydration whose whole session is discarded never compares a before against
an after, so its panes were never offered for judgement at all.
So the window now keeps a census of every pane it has brought into existence,
taken at the one point both spawn routes meet, and judges the census rather
than a list of suspects.
The second half is when. The sweep ran only on a landing that rebuilt from a
full machine tree, and the failures do not lead there — a refused operation
calls `desync`, which primes, and a prime pulls this workspace's mirror
alone. Instrumented, the sweep did not run once across three cycles that
stranded three shells. It now also runs after a prime, and pays for the
machine-wide pull only when a censused pane is not on screen anywhere;
while everything the window made is still showing, that is one set
comparison and no request.
Measured against a live window, same reproducer, same machine:
before 6 create-and-close cycles 6 stranded shells
after 16 create-and-close cycles 0
and nothing else was harmed: four unrelated panes kept their ids and their
scrollback across ten churn cycles and a GUI restart, which is the failure
this has to be judged on — a leaked shell is recoverable with `pane close
--orphans`, a shell killed under a live window is not.
A save is an overwrite of a file that already exists, and the mode belongs
to the file rather than to the write. A host that wrote by creating a fresh
file, or by sending the far side an explicit set of attributes, would turn a
script into something the shell no longer runs — silently, on a save the
user made for an unrelated reason, with nothing in the UI to report it.
Both hosts already get this right; nothing here is a fix. It is the case
that keeps them right, and it runs against the remote link too, where the
write is a control request rather than a syscall. Checked by making the
local write a remove-then-create: the case fails, as it should.
The two sandbox hooks follow the existing symlink one — a host with no
notion of an executable bit returns None and the case skips.
Enter belongs to the input widget and inserts a bare \n whatever the file
around it does. Editing one line of a file checked out with CRLF therefore
mixed the two endings, and git reports that as a rewrite of lines the user
never visited.
The buffer still keeps the exact bytes it was given — that part was already
right, and there are now tests holding it there, along with hard tabs and a
missing final newline. What changed is the save: a file that arrived
uniformly CRLF goes back out uniformly CRLF. Deliberately unanimous, so a
file that already mixes its endings is still written exactly as found rather
than being handed a winner it never asked for.
Saving had no test at all before this; the new one writes a real file and
reads the bytes back.
The grid's own tests cover wide cells and combining marks, but they sit at
the end of the chain: a pty, a ring and a snapshot on a socket come first,
and nothing exercised those with anything but ASCII.
Both spellings of an accented letter go in on purpose. A terminal must not
normalise, so the test asserts not only that each form comes back, but that
neither turned into the other.
`SftpManager` had no live coverage of any kind. Its tests cover the path
arithmetic — `remote_join`, `remote_parent`, `safe_local_name` — and the
transport-failure classifier, none of which needs a server, and all of which
sits downstream of a subsystem no test had ever opened. The whole panel is
built on two calls, `list` and `op`, and neither had been made against sshd.
Now both are, and I ran it: mkdir a scratch directory, create a file in it,
list it and check the entry comes back as a `File`, rename it and check both
that the new name is there and the old one is not, stat a path that was never
created and require the answer to be something other than `Done`, then remove
the file, remove the directory, and confirm listing it fails.
Everything lives under a directory named for the process on the far side and
is removed at the end; the far side being this same machine is what makes it
safe to assert about. Checked afterwards that `$HOME` holds no leftovers.
Takes its turn on the same mutex as the other two, for the reason that one
documents: they share `SshManager::global()`. All three pass together — the
fourth, GSSAPI, still wants a Kerberos realm this machine does not have.
`open_direct_tcpip` is what every local forward and the SOCKS proxy are built
on, and what a remote workspace's control link rides. Its existing tests are
about bookkeeping — which rule is registered, what a teardown reports — and
none of them puts a byte through a socket.
This one does, and I ran it: bind a loopback listener, open a direct-tcpip
channel to it *through a real sshd*, write a line in, have the listener answer
it uppercased, and assert both ends saw what the other sent. A listener of our
own rather than a well-known port, so the test needs nothing to be running and
cannot be fooled by something that is.
Adding it broke the key-auth test, which is the interesting part. Both share
`SshManager::global()`, and sharing is the whole point of it — one of them
asserts that a second `open_connection` is *reused*, and the other evicting the
same key underneath it makes that false. Under `--ignored` they are the only
tests running and they still collide with each other, so they now take turns on
a mutex. Verified: individually and together.
The spec both use is factored into `live_key_spec`, so the defaults that make
this run with no environment at all — localhost, `$USER`, port 22,
`~/.ssh/id_ed25519` — are written once.
tty7 speaks SSH itself rather than shelling out, and remote workspaces, the
routed pane socket and SFTP all stand on the connection it makes. The only
live test beside this one needs Kerberos — the case almost nobody runs — so
the case almost everybody runs had no end-to-end check at all.
It has one now, and I ran it. Against this machine's own sshd:
cargo test -p tty7-core --lib live_key_auth -- --ignored
test daemon::ssh::tests::live_key_auth_connects_and_opens_a_channel ... ok
Key auth against a real server, a channel opened on it, `echo tty7-live-check`
executed, the reply read back and compared, the exit status collected, and
then a second `open_connection` for the same spec asserted to be *reused* —
sharing one transport across panes is the reason `SshManager` exists, and
nothing was checking it either.
Ignored like its neighbour, because it needs a server, but a much smaller one:
Remote Login on the machine running the test and a key it accepts, which is
`ssh localhost true` away. The defaults are `localhost`, `$USER`, port 22 and
`~/.ssh/id_ed25519`, so on such a machine the command above is the whole
setup. `verify_host_keys` is off for the same reason it is next door: the
point is the transport, and a first connection to an unapproved host would sit
on a prompt no test can answer.
One thing the first run taught, kept as a comment: openssh sends the exit
status *after* `Eof`, so breaking on `Eof` reads the output and then reports
no exit code. Waiting for `wait()` to return `None` is the only end worth
waiting for. The output assertion passed on that first run — it was the exit
code that was missing, and it was my loop that was wrong, not tty7.
`civil_from_days` dates the panic log and lives in `core::crash`;
`days_from_civil` lives here. They were written separately, they are each
other's inverse, and each was checked alone — this one by the walk beside it,
that one against four known dates. Four dates leave the century years and
everything before 1970 untouched, which is exactly where era arithmetic earns
its keep.
So they now check each other: 73,049 consecutive days from 1900-01-01, each
converted both ways. Two separately written implementations can only agree
across two centuries if both are right. The walk starts before the epoch on
purpose — asserted, so a later edit cannot quietly move it after — because
negative day counts are the half neither test was exercising.
Verified against the epoch shift, which it catches and which moves every date
in the log by a day.
One mutation turned out not to be one: changing `doe / 146_096` to
`doe / 146_095` is *equivalent* — brute-forced over ±547 years, not a single
day differs — so no test failing on it is the right answer rather than a gap.
Worth writing down, because it looks exactly like a constant somebody typed
wrong.
My own expected day count was off by one on the first run (73,048 for
73,049); the implementations were right and the arithmetic in my head was not.
`days_from_civil` and `days_in_month` are pure arithmetic that no test named,
and every date this crate prints goes through them. Hinnant's algorithm is
correct as written here; what is easy is transcribing one constant wrong, and
a wrong `146097` or `719468` moves every commit date by a fixed amount that
still reads as a plausible date.
Walked rather than compared against a second implementation, so there is
nothing to get wrong twice: step a day at a time from 1900 to 2100 using
`days_in_month`, count as you go, and require `days_from_civil` to agree at
every step. Two centuries of agreement is only possible if both are right — a
leap rule wrong in either drifts them apart within four years and never
recovers — and the count of leap days is asserted at the end so a walk that
skipped February would not pass either.
Anchored first on the epoch, the day before it, and the two century years that
separate the naive leap rule from the real one, so a walk that is merely
self-consistent cannot pass.
Verified against three mistranscriptions: the era length, the epoch shift, and
dropping the divisible-by-400 clause. Each fails it.
Found by looking for pure helpers that no test in their own file names — 607
of them, most trivial or I/O-bound. This pair was the one where being wrong is
both easy and invisible.
Tried the nearest of the two silent paths: parking the seeded panes of the
queued operations `desync` is about to discard. It looks the most tractable of
anything left, because an op that never left this process provably never filed
its pane, so those are stranded by construction.
It is not a fix. It fires rarely — 0 to 2 panes across ten create/close cycles
— and does not reliably reap even those, because the sweep needs a hydration
to settle and a parked pane can outlive the window's next few pulls. One run
parked two panes and one of them was still an orphan at the end. Totals: 4, 4,
5 against a baseline of 5, 5, 5, which is inside the noise.
Backed out, and recorded next to the first attempt, because what the two have
in common is the useful part. Both park at a *site*. The leak does not live at
one — this comment already said so, and two experiments now agree with it: a
sweep phrased against any particular failure keeps missing whichever path was
not instrumented. It has to be phrased against the end state, over every pane
this window spawned, which is what the paragraph above describes and what
nobody has built yet.
No behaviour change.
The measurement is in the code; this is the half a user needs. A tab created
and closed again before the window has finished reconciling leaves its shell
running — 5 of 6 iterations, reproducibly, with no pause between the two
commands. A second's pause, or creating without closing, leaks none, so
somebody working in the window by hand will never meet it and an orchestration
loop will meet it on nearly every pass.
Under Known rather than Fixed, because it is not fixed. What is true is that
the strays are now findable three ways and that one command ends them all;
what is not is that anything ends them for you.
The total was already recorded; the shape matters more, and it is not volume.
Measured against a live window:
tab new / split / send, paced or as fast as the CLI will go 0 orphans
tab new then tab close, 1s apart, x4 0 orphans
tab new then tab close, no pause, x6 5 orphans
The last reproduced exactly — 5 of 6, three runs in a row. What strands a
shell is closing something before the window has finished reconciling the
thing that made it, and the gap that matters is under a second.
That reframes the cost. A person cannot type that fast, so nobody driving the
window by hand meets this. An agent does it by default: a loop that opens a
tab per task and closes it when the task is done leaks a live shell on nearly
every iteration, and that is the shape this product is built for.
The strays are visible — `pane ls --all`, the switcher, and `doctor` since
this afternoon — and `pane close --orphans` ends them. Nothing ends them
automatically, which is what the end-state sweep this comment asks for would
do. Recorded in the changelog under Known, because a user running an
orchestration loop should be told rather than left to find it in `pane ls`.
An interrupted `tty7 run` strands a pane. So does a window reconciling its
layout while something else edits the tree — measured at 17 from 160
operations, every one a live `zsh` holding a pty and its descriptors, and
`tty7 pane close --orphans` ended all 17.
None of that was news to tty7: `pane ls --all` names them, the switcher lists
them, and the reaper works. What was missing is that nothing volunteered they
existed. `doctor` printed `status pid …, up 6s, 2 panes` and counted the
stray among them, so the one verb somebody runs when something feels wrong had
the number and did not say what it meant.
A row and a `server.orphans` count. Deliberately not an exit code: one stray
after an interrupted `run` is ordinary and the reference documents it as such,
so `tty7 doctor || alert` firing on it would cry wolf. What was missing was
the sentence, not an alarm.
Counted inside the arm that already has the machine tree in hand for the
dangling-context row, so it costs no extra round trip, and under `server`
because it takes a server to have panes at all.
The first draft put the count at the top level of the JSON, and
`the_doctor_json_sections_are_the_ones_the_reference_names` — added earlier
today for exactly this — caught it as an undocumented section before it went
anywhere.
The comment here already has the right analysis: panes stranded by a refused
operation are the minority path, the other two throw their panes away without
a refusal, and the sweep belongs after a hydration settles rather than at any
one failure. Two things it did not say, both now measured.
The scale and the severity. 160 CLI operations against a live window left 17
orphans, and every one was a live `zsh` — not a stale record — each holding a
pty and its descriptors. `tty7 pane close --orphans` ended all 17, so the
documented recovery does work.
And a dead end, written down so nobody spends the evening on it twice. Parking
the pane here rather than only warning — handing it to the `sweep_parked` that
already exists, which judges against the whole machine's freshly pulled tree
and spares anything a live view shows — is safe, and does not measurably help.
Four runs: of the panes that reach this arm, 33–60% were still orphaned at the
end without it and 43–50% with it. They are mostly already caught by the sweep
the layout rewrite arms. The leak lives in the two paths that never refuse an
operation, exactly where this comment says the fix belongs.
No behaviour change; the experiment was backed out.
gpui resolves a keystroke by sorting the matches on context depth and then on
registration index, later winning:
matched_bindings.sort_by(|(depth_a, ix_a, _), (depth_b, ix_b, _)| {
depth_b.cmp(depth_a).then(ix_b.cmp(ix_a))
});
`rebuild_keymap` added the config's bindings and then the fixed ones, so a
fixed binding won any tie. Six of the seven are scoped — `Terminal`,
`Switcher`, `Palette` — and win on depth whatever the order. The seventh is
global: `secondary-+`, the font-size step. A config that asked for that chord
got `IncreaseFontSize` instead, with no error, nothing in the keybindings UI,
and nothing in the log — which is the silence this tree has already gone out
of its way to remove from an unparseable chord, a clamped setting and an
unread config key.
Swapping the two lines fixes it and changes nothing else: depth is compared
before index, so the scoped six still win. And it matches what "fixed" was
ever for, which the comment on `fixed_bindings` states — they are not in
`effective_bindings`, so a rebuild replaying only the config would drop them.
That is an argument about existing, not about outranking.
Found by checking the reverse direction of the shortcuts page: every chord it
prints against every chord tty7 binds. Nothing was undocumented — `⌘ C`,
`⌘ V`, `⌃ R` and the arrow shorthands are keys tty7 answers without a keymap
action, which is why a strict guard there would be wrong — but `⌘ +` turned
out to be bound twice, and following that up is what surfaced the ordering.
`the_settings_page_documents_every_section` checked two numbers: that the page
says how many sections there are, and that it shows that many `<Card>`s. Eight
and eight is just as true when one of them has been called something else for
a release, so a rename passed untouched — and the page is what someone reads
before they go looking in the window.
The names now have to match. They come from `SettingsSection::nav_label`, a
new method whose match is exhaustive: a section added to `ALL` has to be given
a name before this compiles, and the nav builder reads the same answer instead
of spelling eight `L10nKey`s inline beside eight variants. One list where
there were two.
All eight agree today. Verified by renaming a card on the page, which the test
now names.
`a_quarantined_tree_can_be_found_by_something_that_wants_to_report_it` carried
two summary lines:
/// What a ninth corruption does to the eight kept copies.
///
/// What `doctor` can find out about a tree that was set aside.
Only the second is about that test. The first belongs to
`a_ninth_corruption_overwrites_the_oldest_kept_copy` forty lines below, whose
own comment opens mid-thought — "`quarantine_path` ends in `unwrap_or(base)`,
which reads like a defensive branch and is the wrap" — with no summary at all.
Same displacement this tree has fixed once before in `daemon::pane`.
Moved back. Both now open with one sentence saying what they are for, which is
what every other test in this file does and what a reader scanning them needs.
Swept the rest of the tree for the same shape — a doc block whose first two
paragraphs are each a lone sentence — and found one other, which is a genuine
summary followed by a genuine second paragraph. This was the only one.
`the_reference_documents_every_flag_of_the_verbs_it_covers` read one level:
`cli.get_subcommands()`, then that verb's own arguments. Two things followed.
It never descended, so every flag a level down was unchecked — `tab new --cwd`,
`pane ls --all`, `pane split --ratio`, `pane close --orphans`,
`server restart --hard`. And its gate for "is this verb documented" looked only
for ``` `tty7 <name> ```, which the page uses for leaf verbs; a group is headed
``` ## `pane` — panes ```. So `ws`, `tab`, `pane`, `machine` and `server` were
skipped whole, taking their children with them. Fifteen flags were checked
where there are twenty-two.
Both fixed: the walk recurses, and the gate accepts either heading shape. The
reference turns out to be complete — nothing was actually undocumented, which
is worth knowing rather than assuming.
The matching rule was too strict as well, and the extension is what showed it:
requiring a backtick immediately before the dashes reported
`server restart --hard` missing when it has its own table row, written as
``` `server restart --hard` ```. It now looks for the flag anywhere on the
page.
The floor is five named nested flags rather than a count, because a count
drifts with the surface and what has to hold is that the walk went down.
Verified both ways: dropping `--orphans` from the page is reported, and
stopping the recursion is reported by name.
`doctor` now says when a workspace tree was set aside — a `machine.json` that
does not parse takes every tab and pane layout with it, and the only notice was
a log line a default install never writes. A panic in the updater is recorded:
it runs detached with its stderr attached to nothing, doing the one job that
can leave an install broken, and it was the one binary of the three without a
crash hook. The side panels accept the widths the reference publishes, instead
of accepting a documented number and quietly drawing something else. And log
timestamps say they are UTC, which they always were.
That brings the Unreleased section from the 51 bullets on main to 103. What is
left in the branch is genuinely minor — internal wording, an icon asset, test
scaffolding — and is the kind of thing this changelog has never listed.
git will not take a control character in a branch name:
$ git branch $'featx\ry'
fatal: 'featx?y' is not a valid branch name
and takes one in an author name without a word about it:
$ git -c user.name=$'Bad\rName' -c user.email=b@b commit --allow-empty -m x
$ git log --format=%an -1 | sed -n l
Bad\rName$
So a cloned repository can carry one into the byline under a commit subject,
which sits in a bar exactly one row tall beside a subject that is truncated to
fit. Ninth site in this tree, and the third whose input is a repository rather
than the machine tty7 runs on.
Both bylines: the diff overlay's `label_byline` and the commit detail's
`byline` are separate functions that had drifted into the same shape, and
folding one would have left the other. The overlay's commit *subject* is
folded at the same site for the same reason — the graph row already does it,
and this bar is no taller.
An author that is only control characters now reads as no author, so the
middle dot goes with it, which is what the surrounding tests already say
should happen for an empty one. Both checked against injected regressions.
`git::status` asks for `--porcelain=v2 -z`, and its own comment says why:
without `-z` "any path with a space, a quote or a newline comes back
C-quoted". Raw is the right thing to read — it is what opens the file — and
the wrong thing to draw. A filename is bytes to the kernel, `touch $'a\nb'`
makes one, and the row it lands in has a fixed height that a mandatory break
grows past and over its neighbour.
The file tree met this and folds its own names. The source-control panel and
the commit detail read their paths from a different place and arrived at the
shared `split_display_path` unfolded, so the eighth site in this tree is the
one place two views share.
Folded there, which is why it now hands back owned strings instead of borrows
— and on both halves, because a directory carries a newline exactly as easily
as a file does. Checked against an injected regression, and the existing
assertions about how the split itself behaves are unchanged apart from their
types.
A commit subject is a name nobody here composed, and git does not clean it:
$ git commit -m "$(printf 'fix: something\rHIDDEN OVERWRITE')"
$ git log --format=%s -1 | sed -n l
fix: something\rHIDDEN OVERWRITE$
Tabs, vertical tabs and form feeds survive the same way. Any repository
somebody clones can carry one, and the graph draws whatever the clone has —
onto a row of fixed `GRAPH_ROW_H`, which a mandatory break inside it grows
past and over the row below. Seventh place in this tree to meet the same
hazard, and the first where the input comes from a repository rather than
from the machine tty7 runs on.
`\r` earns its own line in the comment: it is a carriage return, so the
visible half of that subject need not be the stored half. Folded, both halves
are on the row and neither is pretending to be the whole of it.
Folded before `split_conventional` rather than after, because the split
borrows from what it is handed — which is also why it goes through a named
`row_subject` the test can call, instead of being spelled inline.
The detail panel is deliberately left alone: it shows the whole subject across
several lines under a `line_clamp`, so a break there costs a line it has
already budgeted for. Checked against an injected regression.
Folding names in `normalize_name` covered the names this build stores. Two
kinds get past it.
A workspace with no name of its own is labelled by the directory its first
pane sits in, and nobody named that. `mkdir $'proj\nname'` is enough — checked
against a live daemon, which reports the pane's cwd as
`/tmp/…/proj\r\nname` — and the label grows a row and paints over what sits
below it, the same failure this tree has now fixed six times elsewhere.
And a name can arrive already stored: written by an older build, or read out
of a *remote* machine's tree, where the folding is only as good as the server
that wrote it. The dialect version does not separate those, correctly — the
wire shape never changed — so a peer mid-rollout is the ordinary case rather
than a contrived one.
So both are folded where they are drawn as well: `display_name_of`, which is
the one funnel for a workspace's label, and `tab_label`'s user-name branch.
That is the rule the file tree already follows and states — fold at the point
of drawing — and it composes with the storage-side fold rather than replacing
it: the stored value stays what someone typed, and what reaches a row is one
row's worth.
A pane costs about three descriptors — the pty master and the pipes behind it
— and the daemon took whatever soft limit its launcher happened to have. On
the historic macOS default of 256 that is a ceiling around eighty panes,
against a `MAX_PANES` of 16,384, and nothing in the failure said so:
tty7: spawning a shell: daemon refused Spawn: dup of fd 93 failed
That sentence does not contain the word "file". Measured with an inherited
soft limit of 96, the daemon stopped at 26 extra tabs.
Two halves. The soft limit is now taken up to the hard limit at startup, which
is the ordinary thing a long-lived server does, needs no privilege, and leaves
an administrator who lowered the *hard* limit in charge of the ceiling. Capped
at `OPEN_MAX` because macOS refuses `RLIM_INFINITY` outright and a refused
request would leave the low limit in place, which is the one outcome worth
avoiding. Same measurement afterwards: past 45 tabs and still going.
And when the hard limit genuinely is the ceiling, the refusal says what it is
and what to do, keeping the original in parentheses for whoever needs it. The
re-wording sits where the spawn handler turns the error into a reply, not at
`openpty`: several calls along that path can hit the limit, and libc's "Too
many open files" and portable-pty's "dup of fd N failed" both arrive there.
Matched on the text because portable-pty hands back a message, not a code —
so the test covers both spellings and, more importantly, that a missing
program and a permission error are left exactly as they were.
Verified against a live daemon in both configurations: a low soft limit is
raised past, and a low hard limit produces the new sentence with the daemon
still serving afterwards.
$ cat config.json
{ "shell": { "program": "/nonexistent/shell" } }
$ tty7 new
tty7: spawning a shell: daemon refused Spawn:
no such program on this machine: /nonexistent/shell
$ tty7 doctor | grep config
config ok
Nothing can open a tab, and the verb that checks the install says the config
is fine — which it is, in the only sense that row means: the file parses. The
same table already reports an unusable `custom_shells` entry, and that costs a
menu row. This costs every tab, every `tty7 new`, and the GUI's new-tab button.
The check is the daemon's own `shell_program_problem`, moved from
`daemon::pane` to `core::shells` beside `unusable_custom_shells` so both
callers share one definition and the row cannot drift from the refusal it is
predicting — it prints the same sentence the spawn will. Missing, a directory,
and not executable are told apart, because they are three different fixes.
A program given as a bare name is still not reported: the OS resolves it
through PATH and guessing at that would be worse than silence — the moved
comment says so and the moved tests pin it. Skipped under `-m` for the same
reason the hooks row is: a path checked here would answer about the wrong
machine.
Verified against a live daemon across all five cases — missing, directory,
non-executable, bare name, and no `shell` set — with exit 1 for the three that
break and 0 for the two that do not.
Three different mistakes, one answer:
{ "shell": "/nonexistent/shell" } -> NOT VALID JSON
{ "font_size": 12,, } -> NOT VALID JSON
{ "font_size": "big" } -> NOT VALID JSON
Two of those *are* valid JSON. They are valid JSON in the wrong shape — a
string where a struct goes, a string where a number goes — which is a
different mistake with a different fix, and telling that reader their file is
not valid JSON sends them hunting for a missing comma that is not missing.
serde has already worked out the answer. It names the field, the type it
wanted, and the line and column. That went to `log::warn!` and nowhere else,
and there is no log unless `TTY7_LOG` is set, so on a default install it went
nowhere at all — which is the same shape as the keybinding faults and the
clamped settings this tree has already fixed.
Now:
DOES NOT FIT — invalid type: string "big", expected f32 at line 1 column 20
NOT VALID JSON — key must be a string at line 1 column 19
NOT VALID JSON — EOF while parsing an object at line 1 column 17
`parse_fault` is a helper rather than a field on `LoadOutcome`, which is
`Copy` and crosses several call sites that only want the verdict; this is
asked once, by a diagnostic, about a file already on disk. The window's
notice appends the same detail on its own line — deliberately after the
translated sentence rather than inside it, because serde's message is English
that is not ours to translate and a placeholder would leave a raw parser
string in the middle of a localized one. The notice's own wording needed no
change: it says "could not be parsed", which was true of all three.
$ tty7 tab rename @1 $'one\ntwo'
$ tty7 tab ls --json
"name": "one\ntwo"
A workspace or tab name is only ever drawn as a label, and gpui breaks a label
on `\n` whatever its wrapping says — the tab strip, the tab sidebar and the
switcher would each have grown a row and painted its tail over whatever sits
below. This tree has fixed that four times already: the OSC title, a filename
in the file tree, a remote name in the SFTP panel, a completion description.
Names were the surface it did not reach.
The CLI's own table folds on the way out, which is the part that makes this
plainly wrong rather than merely unhandled: the daemon was storing, and
handing to every other reader, something one of its readers had already
decided could not be drawn.
Folded in `normalize_name`, which `workspace_create`, `workspace_rename` and
`tab_rename` all pass through, so one change covers the three ways in and the
tab strip, the sidebar, the switcher and a remote client cannot disagree about
it. Same rule and same reasoning as `parse_osc_title` — control character to
space, trim afterwards because folding can leave the edges blank.
Deliberately the opposite of the choice made for a *filename*, which is folded
only where it is drawn because it is also handed to `join`, `rename` and
`remove`. A name has no second life: what is stored is what is shown.
Verified against a live daemon before and after — `alpha\nbeta` comes back
`alpha beta`, a tab named with a tab character comes back spaced.