mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
44ce34a9781e9bbf6da4fa34d56bcdb95cc88975
38
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
44ce34a978 |
refactor(tree-sync): one definition of the panes a window answers for
`settle_census` and `sweep_parked` each built `parked ∪ spawned` from the state, separately. I introduced that duplication myself when I added the first of them earlier on this branch, by copying the shape of the second. They are not interchangeable readings. One decides whether the tree is worth pulling, the other which panes to end — so drift means either ending a pane the census never weighed, or pulling for one the sweep will not judge. `census_of` is now the single answer to "what is this window still holding". Which also made the gap visible. Mutating the census showed the `spawned` half held by nothing: `the_census_holds_the_panes_this_window_made` read `s.spawned` straight off the state, so it proved the set was filled in and never that the decision reads it. Dropping `spawned` from the census left the test green while a pane spawned and not yet in the tree went back to being invisible to the sweep — which is the whole of the stray shell leak that set exists to close. It asserts through `census_of` now, on both halves, and each fails when dropped. `stranded_of`'s two conditions were already held. |
||
|
|
c4950bd3ba |
style: run rustfmt over the branch
CI runs `cargo fmt --check` and I had not run it once across this branch, while making most edits by inserting text rather than writing it. 27 files were non-conformant; `origin/main` is clean, so all of it is mine and CI would have failed on the first push. No behaviour change — the suite is identical either side of it. Also checked clippy the way CI does, `--locked --workspace --all-targets -D warnings`, which is stricter than the invocation I had been using. |
||
|
|
cb44751cdb |
fix(switcher): keep live panes off the list that offers to close them
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. |
||
|
|
5b77b51a28 |
fix(tree-sync): collect the shells a window strands, from a census of its own panes
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. |
||
|
|
e91e45f129 |
docs(tree-sync): a second dead end, and what the two have in common
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. |
||
|
|
6635f94f58 |
docs(tree-sync): bound the orphan leak — it is an agent loop, not a fuzz
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`.
|
||
|
|
96b7597497 |
docs(tree-sync): record what the orphan leak measures, and one dead end
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. |
||
|
|
3b5df0b6b5 |
fix(tree-sync): adopting a workspace is not creating one (#716)
`chosen_name` is the name a user typed for a workspace a window is about to create. It travels with the create rather than following it, because a rename sent before the workspace exists is answered `NotFound`. When the create came back without that name, `settle_chosen_name` sent it as a rename. Its own comment gave two reasons the create might not have run — the other create of this window's pair won the race, or the workspace was already there — and treated them the same. They are not the same. The first is this window finishing its own job. The second is renaming somebody else's workspace. #716 is the second one from the far end: a client opened a workspace on another machine that already held nineteen live panes, and the workspace came back named after the connecting client's local user, because that side spent a codename it had rolled for a workspace it thought it was making. A workspace this window's sibling create just made is empty, so the two cases separate on whether the workspace holds tabs — and the existing arbitration tests all pull an empty mirror, so they are exactly the case that still renames. The name is still consumed when the rename is declined. It was owed once, and adopting the workspace is how it stops being owed; parking it would only fire the rename at the next pull. This is the naming half of that report. The tab tree it also lost is not addressed here. |
||
|
|
791d0d0cfa |
Merge remote-tracking branch 'origin/main' into polish/ralph-wc
# Conflicts: # README.md # README.zh-CN.md # crates/tty7-cli/src/cli.rs # crates/tty7-cli/src/server.rs # crates/tty7-core/src/core/config.rs # crates/tty7-core/src/core/git/status.rs # crates/tty7-core/src/daemon/install/wsl.rs # crates/tty7-core/src/daemon/protocol.rs # crates/tty7-core/src/daemon/spawn.rs # crates/tty7-core/src/daemon/ssh/mod.rs # src/terminal/completion.rs # src/terminal/remote.rs # src/ui/app.rs # src/ui/i18n/en.rs # src/ui/i18n/ja.rs # src/ui/i18n/zh.rs # src/ui/tree_sync.rs |
||
|
|
f44b667639 |
fix(restart): fail a silent Attach, and hold the tabs a rebuild could not put up (#673) (#681)
A restart on nightly 26.8.4 came back with every restored coding-agent pane locked: Ctrl-Z printed its suspended message and never returned to a shell, Ctrl-C did nothing, no keystroke reached anything (#673). Its sibling — a restart after an upgrade that came back to an empty workspace (#672) — was mostly closed by #554 and #579; what is left of it is closed here too, because both are the same mistake, a restart's rebuild reporting a success it did not have. The locked panes are an `Attach` the client took on trust. `attach_reply_prefix` reads far enough into the daemon's reply to tell an `Error` frame from a replay, and a read that timed out with nothing in the buffer fell through to the success branch: silence was read as "a quiet pane". But a quiet pane is never silent. `attach_subscriber` replays the pane's ring before the daemon reads a byte of our input, the ring always holds a segment (`ReplayRing::new` starts with one and every path that empties it puts one back), and every daemon build there has been queues a `Size` and then a `Snapshot` first — a pane that has printed nothing still answers with its geometry. So an `Attach` that produced no bytes in the whole wait is one nobody is serving: a daemon still mid-restart, or a socket some process holds open and will never read. Taken for an attach, it made `spawn_shell_terminal_in` report `restored = true`, the flag that skips the fresh spawn, the restored-screen banner and the agent's `--resume`; and `write` threw every encode error away, so the keystrokes, Ctrl-C and Ctrl-Z all went into that socket and vanished. Zero bytes is now the failure it is, and the caller falls through to the path it already had for a pane that is gone — a fresh shell under the old screen, with the resume typed. Nothing changes on the wire. That silence has a second reading, though, and only one of the two is safe to act on. A daemon merely slow to serve — an execve handoff keeps the listener and its backlog across the exec, and a fresh daemon adopts its panes and seeds ids before it takes an Attach — would have served the connection a moment later, and a fresh pane spawned over that live one carries its history across (`history::carry` is written for a dead pane) and starts the agent's resume against a session the old process still holds. So a silent local Attach is confirmed before it is acted on: the client asks the daemon `Version` on a fresh connection, which a daemon answers before it touches any state. Answered, the daemon is up and serving and the attach socket is one it will never serve — the verdict stands. Unanswered too, nobody is serving yet; there is no third path from a synchronous UI-thread call, so the attach still fails, but the error and the log line say which silence it was rather than claiming the pane is gone, since that is the line someone reads while diagnosing an orphaned shell. Only local routes probe: a remote attach already waits fifteen seconds and a second routed connection is a second bridge process. The two-second local budget is unchanged — only a silent connection ever pays it, and N silent panes hold the window still for N of them. `write` also stops swallowing the link refusing input. The first refusal is logged once from the writing side, and unless the reader was retired for a relink the pane is marked exited by the reader's own signal — `exited_flag`, then the `Exit` event — since it is the same socket, only found dead from the writing side first; the reader still raises its own when it gets there, and the handler is idempotent. A retired link stays quiet, for the reason the retired reader does. This is hardening for a closed link, not the cure for #673 — a socket held open and never read accepts writes into its buffer, and nothing here fires; the attach change is what keeps that pane from existing. The tabs that did not come back are the rebuild's licence outrunning what it rebuilt. `tabs_from_session` drops any tab none of whose panes would start; `settle_hydration` then marked the window `informed` as long as *some* tab rebuilt, while the mirror it had just installed still listed every tab the machine holds. The next `sync_window` ran at `SyncScope::Full`, and `diff` at that scope emits `TabClose` for every mirror tab not in `desired` — which the dropped tabs were not, and `held` did not cover them: it only covers tabs on screen whose panes cannot be represented. A partial rebuild deleted from the machine exactly the tabs it had failed to rebuild, panes and all. They are held now, rather than the licence withheld. `settle_rebuild` records the wanted ids the window is not showing (`not_rebuilt`), and `sync_window` carries them into `held`, whose contract in `diff` is already "mirror tabs the window cannot speak for — close nothing, and do not reorder around them". Withholding the licence would have been the smaller change, and it is what the none-rebuilt case does, but it takes `TabClose` away from the whole window for as long as the failure stands, and a failure can stand across every restart (a tab whose shell is no longer on the machine): every close the user made in the meantime would come back on the next rebuild. Holding only the tabs that failed leaves the window speaking for the ones it did put up. The set is rewritten by the next rebuild and pruned against the mirror on every sync, so a tab the machine lets go of stops being held. The none-rebuilt guard is unchanged: a window that put nothing up still does not speak for the workspace at all. Two things about the held set said out loud. It reads the count of tabs the tree asked for, not the ids it found: `tree_id` is not serialized, so a session that reached this path from disk would name no ids, and "no ids" must not read as "no tabs wanted" — that would hand the licence to a window that rebuilt nothing, which is #672 again. And holding has a cost with no retry: `diff` stops before its reorder pass and the active-tab op whenever anything is held, and nothing rewrites the set but the next rebuild — a re-prime and an `IfEmpty` hydrate on a populated window never get there — so a tab that fails to rebuild holds the window's tab order and active tab off the machine until the next restart. That state was already reachable, since a pane whose remote spawn failed stays connecting for the same span, held the same way; this widens a standing hole rather than opening one, and a retry, or a way to close a held tab from the window, is separate work. |
||
|
|
f828e9ce22 |
fix: reunite three more doc comments with the functions they describe
The same defect as the previous commit, found by sweeping for it: a doc
block holding one item's summary followed by another's, with no blank
line between, so rustdoc attributes the whole thing to whichever item
happens to follow and the other is left undocumented.
daemon/pane.rs "The pane's screen, capped for storage, with the mark"
describes `scrollback_snapshot`, which returns exactly
that pair and had no documentation. It was sitting on
`scrollback_mark`, in front of that function's own.
ui/tree_sync.rs "This workspace's layout, and the name the machine has
for it" was on `settle_chosen_name`, which settles a
name and returns one. `git log -S` puts it above the
signature returning `(WsMirror, Option<String>)` --
`pull_or_create` -- so it was moved, not guessed.
core/ssh_config The "does this alias still resolve" paragraph, with the
four-times-a-second cost that explains the cache, was
on `struct AliasCache` rather than on
`alias_still_resolves`.
A fourth hit was not this: `file_tree_reveal_path` really does own both
of its paragraphs, and only wanted the blank `///` that separates them --
without it rustdoc runs "use file_tree_show when the tree *is* the
answer" straight into "returns whether the tree could hold it".
Nothing catches any of this. A fused block is well-formed rustdoc, so
`-D warnings` passes; the only symptom is a function with no docs and a
neighbour with somebody else's.
|
||
|
|
43961cae20 |
docs: restore seven invariants that the comment strip took out
#268 ("strip every comment from the Rust sources") removed 38,164 comment lines. Much has been rewritten since, but 7,587 blocks still sit in front of code that is unchanged and now undocumented. Last commit I started implementing a pair of deliberate no-op stubs before `git log -L` turned up the stripped comment saying they were deliberate — which is the failure mode this class of loss produces. Seven restored here, chosen for documenting why something must not change rather than what it does: - `LinkShutdown` — "not optional politeness; without it a client cannot be closed". A reader parked in a blocking read is not woken by any flag, so closing has to act on the fd, and no std trait spans the transports. Nothing else says this. - `peek_frame_kind` / `is_error_kind` — why Attach is classified before the payload is paid for. - `FontFeatures` — a frozen config key deliberately replicating gpui's type so tty7-core parses without linking gpui. - `desired_tabs` / `every_leaf_is_native_ssh` — held vs permanently-invisible tabs, and why conflating them either deletes a daemon tab mid-revival or freezes a window's ordering forever. - `control_for` / `TreeLink` / `classify_tree_link` — local and remote links unified, and unserved as a fact about the peer rather than a transient down. Restored against the current code, not verbatim: three claims had gone stale and are corrected. `Duplex` has since adopted `LinkShutdown` (`Halves` carries one) rather than needing to; `peek_frame_kind` has two callers now, not one; and `gpui_font_features` moved out of `ui::app`. |
||
|
|
74d647e5ff |
refactor(tree_sync): make the sweep's decision assertable
Ending a pane was one function: work out which parked panes nothing holds, then hang them up. Only the second half needs a running daemon, and mixing them meant the rule could only be checked by watching a fuzz run -- which swings from fifty operations to five between runs on the same seed. That is how a change of mine looked effective for five iterations while doing nothing at all. `stranded_of` is now the rule on its own, and a test states it directly: a pane on screen is never ended whatever the tree says, a pane the tree still names is never ended whatever the window shows, and only one held by neither goes. Each of those three has been the difference between a leaked shell and a killed one at some point in this file's history. No behaviour change -- same filter, same order, called from the same place. Verified the test earns its keep: dropping the `showing` check from the filter fails it with `left: [1, 4], right: [4]`. |
||
|
|
976bd36566 |
fix(tree_sync): ask every window before ending a pane, not just one
Both hang-up paths guarded on "the window that raised this is not showing the pane". The answer they are guarding is the daemon's, and the daemon's is machine-wide: `collect_orphan_panes` names any registered pane no workspace holds, whichever window spawned it. So with two windows on one machine, the second could spawn a pane, have it registered, and still have its TabCreate queued -- and a close raised by the first would see it named as detached, find the *first* window is not showing it, and end it. A pane the other window is about to draw. The same mistake as judging a pane against a tree that has not caught up, one window over. Now the guard is the union over every open window, and a window that will not answer abandons the sweep rather than shrinking it: one that cannot be read might be showing anything. Found by re-reading my own two commits rather than by a failure -- the fuzz that verified them drives a single window, so it could not have caught this. |
||
|
|
489c394837 |
fix(tree_sync): end the shells a layout rewrite leaves behind
The window drops a view whenever its layout is rewritten -- a delta arriving for a tab it had built differently, a rebuild from the tree -- and dropping a view does not end the shell behind it, because that is also what detaching is. So every reshape left panes running that nothing could reach again. This is where most of them came from: a 150-op fuzz against an open window shows 31 layout rewrites to 2 rebuilds and 12 refused ops. Panes put down this way are *parked*, not ended, and judged later. At the moment of the drop neither mirror can say whether a pane is still somebody's: the machine copy is missing whatever this window has just done, since a client is left out of the deltas its own ops raise (#612), and this workspace's copy runs ahead of or behind ops still in flight. Judging there does end live panes -- both a machine-mirror check and a two-mirror check were tried, and the fuzz caught each of them killing panes the tree still named. The parked set is swept only against a tree the window has just pulled, which is the one account of the machine here not assembled from deltas, and only for panes the window is also not showing. Anything that came back into the tree or onto the screen meanwhile is forgotten rather than ended, because that is exactly what the mirrors were wrong about. Over six fuzz seeds: no run ends a pane the tree still names, and where a pull lands during the run the stranded shells go from 15-26 to 0-2. A run with no pull sweeps nothing and leaks as before -- the parking is correct, the coverage is only as good as how often the window pulls -- and `tty7 pane close --orphans` still reclaims whatever is left. |
||
|
|
1b75500b88 |
fix(tree_sync): hang up the panes the tree hands back
TabClose and PaneClose answer with the panes the tree let go of, and the shells behind them keep running until somebody hangs them up. The daemon will not: the same removal is how a pane crosses to another tab, so it cannot tell an ending from a move, and whoever sent the op has to say. `tty7 pane close` always has -- hang_up_removed_panes exists for exactly this. The window did it only where a *person* closed something, and never for the identical ops its own reconciliation raises, because `pump` sent the batch with `if let Err(e) = client.call(op)` and dropped the Ok on the floor. Every pane the window put down while squaring its layout with the tree left a live shell nothing could reach. Measured with a 150-op tree fuzz driving the CLI against an open window: 5-6 shells per run were being stranded this way, and are not any more. It is not the whole leak -- around 14 orphans per run survive, from the paths ITER 13 named (a superseded hydration, a delta that would not apply), which strand panes without sending an op at all. A pane the window is showing is never hung up, whatever the tree says. The daemon computes its answer with collect_orphan_panes across the whole machine *after* the change, so a pane that merely moved is never named -- but a pane this window has spawned and registered and not yet placed in the tree is, because at that instant nothing in the tree holds it, and its TabCreate may still be queued behind the op that produced the answer. Ending that one takes down a pane the user is looking at (#628). If the window cannot be read at all, nothing is hung up: a leaked shell is recoverable with `tty7 pane close --orphans`, a shell ended under a live window is not. The check runs on the main thread after the batch returns rather than beside the calls, because `pump` may be running inside an App that already holds the window entity, and gpui answers that by silently dropping the update -- which stops the window syncing at all. |
||
|
|
d76ed6df50 |
diag(tree-sync): correct what the strand-a-pane note claims, after measuring it
The note said refused tree operations are how this window strands a shell. They are one way, and the minority one — which matters, because it is the way that would attract a fix. Measured with a randomized tree fuzz over the CLI (70 operations: tab new, split, pane close, tab close, tab move, invariants checked after each): daemon only, no GUI 0 orphaned shells same run, window open 16 orphaned shells, 12 panes actually held either way A second run of 70 left 10 orphans, and the warning this note sits on fired for 3 of them. The other 7 were spawned for a layout the window then threw away without sending an operation at all — the log shows "dropping a superseded hydration" and a delta that "did not apply cleanly" forcing a re-pull. Nothing is refused on those paths, so nothing reaches this arm. They are live `zsh` processes, not bookkeeping: each orphan answers `tty7 procs` with a pid. So the note now says which share this path accounts for, and that the sweep has to be phrased against the end state — a pane this window spawned that neither the machine tree nor any live view holds is stranded however it got there — rather than against any single failure, which would fix a third of it. Still diagnosis only, for the reason already recorded: the sweep belongs after a hydration settles, and verifying it needs a GUI this environment cannot watch. 2949 tests pass. |
||
|
|
23bbe7d400 |
diag(tree-sync): name the pane a refused tree operation strands
`TabCreate`, `PaneSplit` and `PaneReplace` each carry a pane the window has already spawned — the shell is running on the machine before the tree is told about it. When the tree refuses one of those, nothing ever takes the pane and the re-pull leaves the window without the tab it was for, so the shell goes on running with nothing referencing it. The log said only that an operation was refused, which is the one thing that does not point at the leak. Reproducible against a running instance: with a window open on a workspace, `tty7 tab new` and `tty7 tab close` back to back. The GUI restores the new tab, finds its pane already hung up, spawns a replacement, and `PaneReplace` is refused because the tab is gone. Four cycles in five leak a shell; leave half a second between the two and none do. Killing the GUI and repeating the loop leaks nothing — the CLI half is correct throughout. Not swept here, and the comment says why: at the point of the refusal the window still holds a view for the pane and only drops it once the re-pull lands, so hanging it up here would kill a pane that is still on screen. The sweep belongs after the pull settles and has to test the whole machine tree rather than this workspace's mirror, since a pane belonging to another workspace on the same host is not this window's to end. That change wants a GUI it can be watched in; this one only makes the leak say its own name, and `tty7 pane ls --all` already points at the recovery. `seeded_pane` is pinned by a test, because the set of requests that name a pane into existence is exactly the set a refusal can strand one from — a new one added without it would leak silently. 2933 tests pass. |
||
|
|
c957231142 |
chore(lint): put clippy on the CI gate, and clear the ~200 findings behind it
CI checked `cargo fmt --check` and the build, so nothing ever read the content of the code — ~200 clippy findings had accumulated, a third of them in `#[cfg(test)]` modules. Two of them were real: - `resolved_background_appearance` took `backdrop`, and only the `#[cfg(windows)]` arm used it. Renaming it to `_backdrop` is what clippy asks for and compiles cleanly on macOS; on Windows it is an undefined name. Kept the parameter and discharged it in the non-Windows arm the way `package_for_current_install` already does. - `SettingsSearchBackdropKeywords` and seven other `L10nKey` variants were carrying translations in three languages for strings nothing reads. The rest is mechanical: let-chains for collapsible `if let`s, struct-update syntax for `Default::default()` reassignment, `sort_by_key` where a manual reversed comparator was doing the same job the same file already did with `Reverse` two functions later. Where clippy was wrong, the reason is now in the tree rather than rediscovered: per-platform `#[cfg]` blocks each keep their `return` (dropping it only compiles on whichever target's block lands last), the loopback parser keeps three parallel arms instead of folding one into a `?`, and the four wide enums are all built on the stack and consumed immediately, so boxing them would add an allocation rather than save one. `L10nKey` cannot be clean under `dead_code` on any single platform, so the allow there records how to audit it instead — which is how the eight dead keys were found. `english()` gained the cross-platform test its doc comment already claimed; only the Windows hint had been pinned. The clippy job is non-required until it has green history, for the same branch-protection reason `host-boundary` documents. 2930 tests pass. |
||
|
|
9fc0f331e8 |
feat(tabs): drag a tab in as a pane, and a pane out as a tab (#651)
* feat(tabs): drag a tab in as a pane, and a pane out as a tab A tab dragged by its chip or its sidebar row can be dropped over the panes to become one of them, and a pane dragged by its grip can be dropped on the strip or the sidebar to become a tab of its own. Both carry the panes across as they are: nothing is spawned and nothing is killed, so a shell mid-command, an SSH session or an agent mid-turn keeps running. The landing is read the way a pane drag's already is, minus the middle: an arriving tab has nothing here to trade places with, so a pane's core means "split it the way it is longest". A tab that was itself split arrives with its own shape intact and takes one share of the row or column it joined. A pane on its way out is offered a caret between two tabs, and the last pane in a tab is offered nothing, being a tab of its own already. Picking a tab up no longer switches to it: the strip and the sidebar now activate on the click rather than on the press. Without that the merge cannot be expressed at all — pressing the tab to drag it would put it on screen, leaving no other tab to drop it into. Two things in the machine tree had to follow: * Panes that change tabs are told as PaneMove, one at a time, rather than as a tab closing and another being rebuilt around them. * The tabs the machine already has are reconciled before new ones are created, so a pane leaving for a tab of its own is given up by the old tab before the new one asks to register it. The machine refuses a pane that is in two tabs at once, and the refusal desynced the window. Closes #621 * test(tree-sync): a tab grafted above a whole layout still converges * fix(tabs): keep a click on the close button from switching tabs Switching on the release rather than the press means every click inside a chip or a sidebar row now reaches the row itself, and gpui-component's `Button` does not stop propagation on a click it handled. So one click on a tab's close button ran `close_tab(i)` and then `activate(i)` — with `i` by then naming whichever tab had slid into that slot, which moved the active tab somewhere nobody asked for. A click into the rename field did the same: it switched away from the tab whose name was being typed, and took the focus out of the field with it. Both now hold the click where they handled it, the way they already held the press. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
7fded5afd4 |
fix(tree-sync): record the panes a window seeded in its own mirror (#628)
A window's mirror of its machine held no `PaneRecord` for a pane the window itself created. Records ride inside layout deltas and a client is left out of the deltas its own ops raise, so the record the daemon mints when it registers a seed reached every window but the one showing the pane; `PaneFacts` closed the gap only when a fact changed, and a pane spawned into its directory and left at its prompt never changes one. The workspace answered no subject path, an unnamed one read "Untitled", and `record_geometry` stamped a null subject over the path views.json remembered. The window knows what it seeded, so it now puts those records into its own mirror through the same `PaneSeed::into_record` the daemon mints with — opened up rather than duplicated, so ssh-secret stripping stays shared — with the window's own Ready terminals standing in for the daemon's liveness probe. The write is insert-only: a record the mirror already holds came from the machine and outranks what a seed knows. Also closes the race that reopened the same symptom by another route: a `MachineGet` already in flight installed its tree whole and took the client's not-yet-acknowledged writes with it, and nothing re-inserted them until the next non-empty op. All four optimistic writers now go through one path that keeps each write for the life of a pull in flight and replays it over the tree that lands. The tree stays authoritative for everything it speaks about — only the ops it was built too early to know are put back on top, and the journal is drained once it has landed, so nothing a later tree dropped is resurrected. This covers #604's pushed tabs and workspace ops, not only the seeded records. Fixes #612. |
||
|
|
e781bfddc6 |
fix(workspace): let a new workspace keep the name it was given, and cover the abort #618 fixed (#619)
A name typed into the create form was sent as `WorkspaceRename` the moment the window switched — before the workspace existed on the machine to be renamed. The machine answered `NotFound`, `unsendable` logged it at debug and dropped it, and the create that ran afterwards named the workspace whatever codename it rolled. Nothing replays it: the next sync diffs tabs, not names. It flashed on screen first, because `fire_workspace_op` notes the op in the mirror before sending it, so the name appeared and then reverted. The name now travels with the create instead of chasing it. It is parked on the window's sync state by `name_new_workspace` and spent by whichever create runs — `pull_workspace`, which is the one `switch_workspace` actually reaches, and `pull_or_create`, which races it (both create when the tree they read did not hold the workspace yet, as the `Err` arm of `pull_workspace` already described). Both read it inside their spawned task rather than before it: a window orders its pull first and is named second, so anything read earlier is still empty. `settle_chosen_name` arbitrates against what the machine answers. A name it read back was spent by the create. One it did not means the create never ran — the workspace was already there — so it goes out as the rename it has become. A window that chose no name still reads whatever the machine says, which is what #604 fixed. Also covers the abort #618 fixed a commit ago. That fix is right and is left as it is; it landed without a test, and `tabs_on_screen` opening with `if !cx.has_global::<WindowRegistry>()` is why the whole suite passed over the read below it — no test installs a registry. The test here installs one, which is what `WindowRegistry::register` is no longer private for, and fails with an abort against the code as it stood before #618. Six tests, each confirmed to fail without the change it guards. Not verified end to end: there is no fake control client in the tree, so what `WorkspaceCreate` carries over the wire is covered by reasoning and unit tests only. |
||
|
|
1d0b648f66 |
fix(tree-sync): stop workspace switches from rereading the app mid-update (#618)
fmt |
||
|
|
70ac7f201f |
fix(tree-sync): tell a window the name the machine gave the workspace it made (#604) (#613)
A workspace a window creates is created with a generated name, and that name is what `tty7 ws ls` prints and what `tty7 ws rename` addresses. The window itself was never told it. A client is left out of the deltas its own ops raise, so the WorkspaceCreated delta carrying the name never comes back, and both create paths threw away the copy the reply carried: `pull_or_create` reduced the answered workspace to its tabs, and `pull_workspace` returned the tree it had read before the create, which does not hold the workspace at all. The mirror therefore held the workspace unnamed, the chip fell back to the directory its shells started in, and the GUI and the CLI gave two different answers to what the workspace was called — a user could read `verify-main` on screen and not address it by that name. The first pull of the whole tree, which a daemon restart, a rebuild and a plain relaunch all do, then produced the name it had had all along and looked like a rename that stuck. Both paths now carry the name they were answered with: the prime hands it to the mirror, and the hydrating create puts the workspace it made into the tree about to be installed. A workspace the machine really has no name for still reads the directory it is working in, and a chosen name still wins. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
84d6fc223a |
fix(tree-sync): stop an abandoned Replace debt deleting the tabs it was pulling (#579) (#608)
`take_rehydrate` dropped an owed `Adopt::Replace` whenever the window had any tabs, on the reading that the user had filled it in themselves. That reading only ever fitted the one caller it was written for: Restart Server, which empties the window before it resyncs. Every other `Replace` — a daemon back as a new process, a restart handoff that was refused, a remote server restarted, a layout delta that would not apply — is ordered over a window that still holds its tabs, because those stale tabs are the whole reason it was asked. The retry was abandoned on its first attempt, every time, and the resync silently did nothing. Abandoning it also left the window `informed`: still licensed to prune a workspace whose layout it had never read. `start_prime` then refilled the mirror from the machine, and the next `SyncScope::Full` diffed whatever the window happened to hold against it — one tab opened over an emptied window became `TabClose` for every tab on the machine, deleting those panes' records while their shells kept running, with nothing tree-driven left that could reach them. The damage #554 describes, through a door #554 did not close, and the same on a remote window. So the debt is now scoped to the layout it was owed over: `hydrate` records the tabs on screen as it orders a pull, and only a tab that debt never saw counts as the user moving on. And a `Replace` takes back the `informed` licence up front, the way `on_preempted` does, so a window waiting on a rebuild adds to its machine without pruning it until the pull lands — however the debt ends. Pre-existing since #472 ( |
||
|
|
1987766a14 |
fix(daemon): rebuild the window from the tree when a restart handoff fails (#554) (#564)
* fix(daemon): rebuild the window from the tree when a restart handoff fails (#554) Restart Server clears the window's tabs before attempting the handoff, and the Err branch then only showed a reason on the home page. A refused handoff leaves the daemon exactly as it was — still serving the panes the window just dropped — but every restore path is tree-driven, and the next sync of the emptied window diffed into "close every tab" against the mirror: pane records deleted under shells still running, or the whole workspace removed if the user simply closed the window first, right after the dialog promised nothing would be interrupted. The Err branch now runs the same invalidate + resync the Ok branch runs, pulled out as tree_sync::resync_after_local_daemon_change. Where the daemon really is gone (an exec that never re-listened), the pull misses and the rehydration debt is what keeps the empty window from being pushed up as the layout. The failure is also toasted, since a successful resync takes the window off the home page where the reason was shown. * test(tree-sync): anchor the emptied window giving up its mirror The fix for #554 rests on one property that nothing asserted: after a restart that failed, the window must stop speaking for the machine. An emptied window still `informed` over a `Primed` mirror is the shape that does the damage — the next sync diffs it into "close every tab", and closing it authorizes a `WorkspaceRemove` outright, while every shell it named is still running on a daemon that never went anywhere. The handoff itself is UI-plus-daemon integration and stays uncovered, but the guard is not: the pull drops the mirror, clears the ops queued off the emptied window, and takes the workspace back out of disposable range, all synchronously and before any link is needed. Pinned on `resync_window_from_tree`, the per-window step both of the helpers above it work through — which of them a caller picks decides whether the link is dropped first and which windows are walked, not this. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
557bfd3f3a |
fix(ui): resync local windows when the daemon comes back as a new process (#553) (#569)
* fix(ui): resync local windows when the daemon comes back as a new process (#553) From the client's side a killed daemon is indistinguishable from one whose shells all exited at once — its DeathReporter says nothing while it shuts down, and a taskkill says nothing ever — so the window kept showing every pane with its last title, and the reconnect then pushed that dead layout back up as the new daemon's truth. On Windows a force-killed daemon can even leave the shells themselves alive but permanently unreachable. The control handshake already carries an instance id per server process, and the remote path already compared it (server_restarted); the local link never did. Now LocalLink remembers the hello instance — across invalidate(), deliberately, or the restart path's own first move would blind the comparison — and a changed instance on reconnect routes every local window through resync_after_local_daemon_change instead of on_link_up: the dead link is dropped first, then hydrate rebuilds from the machine tree and each pane comes back from its scrollback snapshot with the "new shell" banner. note_instance moves to tree_sync as the shared comparison (empty instance = server predates the field, never a restart, never overwrites), with the remote callers and their tests repointed. The restart-server action now uses the same helper instead of open-coding invalidate + resync, which also fixes it rebuilding only the current window when several local windows are open. Per the review, the title wording ("process exited" while the link was down) and the missing Respawn menu item are a separate PR: the current wording is pinned by a_dropped_link_does_not_claim_the_process_exited and changing it is its own decision. * docs(changelog): entry for the local-daemon reconnect resync (#553) * fix(ui): keep the reconnect's own link when the daemon came back new The reconnect installs the new daemon's client and then, on a changed instance, called the helper that starts by dropping it. The link the handshake had just proved good was thrown away, every window's pull went out with nothing to send it on, and the layout had to wait for the next tick to connect a second time. That is not only wasted work. `hydrate` gives the link fifteen seconds to come back and then owes the window a `Replace` — and a `Replace` is abandoned the moment the window has tabs, which in this scenario it always does: the dead ones still on screen are the whole bug. So a slow second connect turned the resync into nothing at all, silently. Split the helper: `resync_after_local_daemon_change` still invalidates first, for the restart-server path whose link really does point at a server it killed, and `resync_local_windows_from_tree` is the half the reconnect wants, with the fresh link left in place. The two note_instance tests that moved here with the function now say what tree_sync's own tests say; the one that is this module's — the slot is per host — stays. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
e4bd49c39c |
fix(launch): restore the layout when a launch names a directory (#529)
"Open in tty7" from Explorer, and `tty7 <PATH>`, skipped the session restore whenever no window was already up — `restore_session` said nothing about it, the condition was simply "an explicit path was given". The folder arrived as a lone blank terminal in a brand-new workspace and the previous tabs were left behind: still running on the server, still `open: true` in views.json, and reachable only through the switcher. With a window already up the same menu entry had always just added a tab to it, so one entry had two meanings depending on whether the GUI happened to be running. Both shapes now restore first and open the folder as one more tab. The folder cannot be handed to the new window as its first terminal: `Adopt::IfEmpty` declines to adopt into a window that already has a tab, and the pull would then push that one tab back as the whole workspace — writing the layout it was restoring off the machine. So it travels with the hydration and is opened by whichever attempt settles it. A path still declines to follow the layout onto a remote workspace and starts a fresh local one there, which is the case the old blanket skip was really guarding: the directory it names is a path on this computer. Fixes #527 Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
af3928da1e |
fix(tree-sync): pay back a remote window's owed tree pull (#472)
* fix(tree-sync): pay back a remote window's owed tree pull A window opening onto a remote workspace is empty until `hydrate` pulls the machine's tree and rebuilds its tabs from it, and it has to be: an empty window diffs into "close every tab", so `sync_window` holds anything back until the pull lands. When the pull fails, `owe_rehydration` records the debt and returns, on the promise in its own doc comment that the next sync settles it — "which is what a reconnect does through `on_link_up`". `on_link_up` is called for `HostId::LOCAL` and nowhere else. On a remote host the debt was only ever settled by a reconnect completing, by an edit in the window, or by restarting the app. So a pull that failed while the link stayed up was never noticed again: no reconnect, and an empty window has nothing in it to edit. The window sat on the home page with every tab and every shell still on the machine, and only a restart brought them back. Two ways to fail a pull with a healthy link, both routine. A `MachineGet` can overrun its ten seconds on a slow link. And a `WorkspaceCreate` can lose its race with `start_prime`, which runs the same create from the other side of the same window opening — that one fires on every remote workspace opened, and is only invisible because the workspace it usually lands on is empty anyway. So: arm a backed-off retry when the debt is taken on, drive it through `sync_window` where the rules about whether a window may still adopt the machine's layout already live, and stop treating a lost create as a failure — read the tree again and hydrate from what is really there. `on_link_up` is also wired to a remote link coming up, which is what the comment always claimed: a link the switcher connects finishes no attempt, so nothing told its windows the machine could be reached. * fix(tree-sync): end the backoff with the run of failures, and stop shouting Review follow-ups on the owed-pull retry. The attempt count paces the retry, so it has to mean "failures in a row", but it was only cleared when a hydration landed. A debt abandoned rather than paid — a `Replace` dropped because the user filled the window in themselves — and a prime that landed both left it standing, so the next first failure waited the 30s cap on an outage that was already over. It is now cleared wherever the run ends. A window left open on a machine that is really gone retries forever by design, which meant a warn and an info every ~45s for as long as it stayed open. Once the backoff settles at its cap those lines stop being events and become a fact about the machine, so they step down to debug. The retry is exactly as persistent; only the volume drops. Also: report the create's own refusal when the reread finds the workspace still missing, and say at debug that the reread happened at all — the race recovery was silent, so the extra round trip was invisible when reading a log. And correct the comment on the window-gone guard: closing a window drops its whole `WsState` through `forget`, debt and all, so nothing is parked for the next opener. Tests: the count ends with the run at all three sites, the level steps down at the settle point, and the armed retry is driven through a real timer (advance_clock) into the window-gone guard — the first coverage of the retry actually firing rather than of the predicate it consults. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
c138be687a |
fix(daemon): keep a pane's shell and its screen across a restart
Two things a pane lost when the background service stopped and started, both of them things the tree was the only possible place to keep. **The shell.** `PaneRecord` and `PaneSeed` carried a pane's cwd, its ssh spec and its agent, but never what it was running. A window rebuilding a dead pane from the tree therefore had nothing to pass and spawned on whatever the default shell is now — so a restart turned a bash pane into a PowerShell one, quietly and in place. The daemon resolves the override against the config at spawn time and is the only party that knows the answer, so it keeps it and reports it; the seed carries it too, for the panes a window spawned itself. A handoff carries it in the blob, because nothing on the far side of an `execve` can work out the command line of a child it never spawned. **The screen.** The startup sweep ran before the endpoint was listening, which is the one moment nothing can answer the question it asks: the registry is empty and the windows that know which screens are still wanted cannot say so yet. A tree that failed to parse made it worse — `read_machine` quarantines it and returns an empty `Machine`, so one bad file took every pane's stored screen with it. The sweep now happens only on the periodic pass, a tick later, with the registry filled in and the tree caught up; nothing is serving a request in between. Turning the setting *off* still clears the directory at once, because there the promptness is the whole promise. Two smaller ones alongside it: `restorable_pane_ids` now counts the tree's pane list and not only the panes some tab currently stands on — the two disagree while a window is between layouts, and being wrong costs a file swept a tick late in one direction and somebody's terminal in the other. And `restored_screen` drops the snapshot file *after* deciding it was not empty, so a snapshot holding nothing is no longer consumed by the request it could not answer. The restore path had no end-to-end test, which is how this shipped: the unit tests cover the file, not whether a window that reattaches is shown anything. The new one runs a real daemon, puts a marker on a real pane, stops the daemon, starts another, and reads the wire. |
||
|
|
86799220ca |
feat(splits): rearrange a tab's panes by dragging one onto the layout (#445)
* feat(splits): rearrange a tab's panes by dragging one onto the layout Hovering a pane floats a small grip along its top edge; dragging it picks the pane up and puts it somewhere else in the same tab. Three landings, resolved from where the pointer is: * a pane's edge — split that pane and take the side dropped on * a pane's middle — trade the two panes' places * the band along the outside of the tab — sit beside everything else as a full-width or full-height band, which is the only way to say "make this a full-height column" in one gesture from the middle of a 2x2 The landing is highlighted while the drag is in flight, and is offered only once the tree agrees the drop changes something, so the highlight is never a promise the drop does not keep. * pane: move_leaf / move_leaf_to_edge / swap_leaves, each built on a clone and installed only when the layout really differs * pane_drag: the pointer-to-landing geometry, the drag state, and the grip * tree_sync: reconcile a tab that kept its panes but changed shape with a single PaneMove instead of closing and rebuilding the tab * feat(splits): drop a pane beside its neighbours, not on top of one Trying the drag out on real layouts turned up three ways the drop model asked for more precision than it should have. A drop on a pane's side always halved that pane, so putting a new column into a row of columns was only reachable at the very edge of the window, where the band rule took over. A side facing a neighbour in the same row or column now joins that run: the newcomer takes an equal share and the others give it up in proportion, keeping whatever relative sizes they were dragged to. A side facing across the run has no run to join and still halves the pane it landed on. The band along the tab's edge was a flat 26px, which on any real window is a hair's breadth. It is now measured against the pane it is read in — a sixth of it, floored at 32px and capped at 120 — and only counts on a side that faces the window rather than another pane. Landing there takes an even share of the columns that side already has instead of half the tab, so a third column is a third and not a half. The highlight is no longer drawn from the rule. The drop is carried out on a deep copy and the dragged pane's new rectangle is measured off it, so the preview and the result cannot disagree; the copy is deep because sharing a run out writes ratios the live tree's splits hold in common. Also: the grip is a quiet 22x3 bar that grows to 40x5 under a fixed 56x10 target (it needs an id of its own, or gpui settles its size before the group-hover is known), and every rearrangeable pane keeps an 8px strip clear above its grid so the grip never sits on the first row. * fix(splits): pin a drop to the pane it was offered against Review follow-ups on the pane drag. A drop zone named its target by position in the tab's leaf order, but it is read on one frame and carried out on the next: a pane closing in between shifts every index after it, and the drop lands beside a pane the user never aimed at. The zone now carries the target itself once the frame that drew it has resolved it, so a target that has gone refuses the drop instead of sliding it sideways. Alongside it: * `Pane` is no longer `Clone`. The two copies it can be asked for differ in whether they share their splits' sizes, which is not a difference to leave to whichever one `.clone()` happens to mean; `shallow_clone` is now named and private, next to `deep_clone`. * `edge_landing` no longer hands back a share that only a test read. The test reads it off the split the landing produced instead, which is the number the drop actually lands. * A test pins the invariant the drop zones rest on: `leaf_rects` comes back in the order `leaves` does. * Drop a doc comment that had landed on `close_focused` describing a different method, and an `Option` in `drop_pane` that was wrapped only to be unwrapped two lines later. * The changelog claimed every rearranged tab now syncs as one `PaneMove`. Only a drop beside a single pane does; a drop beside a whole group is not something `PaneMove` can name, and still takes the rebuild. Both entries move under `Unreleased` — v26.8.2 was tagged before either landed. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
09a653d10b |
fix(workspace): make the CLI and the GUI agree on what exists (#423)
Five places where a workspace, a tab or an attachment was real on one side of the socket and invisible on the other. They share a root: the GUI kept its own list of which workspaces exist (WindowViews on disk) and consulted the machine tree only for the ones already in that list, so anything created by another client was unreachable by construction. - The switcher lists workspaces the machine holds but this client has never opened, and opening one keeps its id instead of claiming a fresh one. - for_workspace_at hydrates whenever the machine holds tabs, so opening a workspace no longer saves an empty session over them. - finish_hydration writes a full window back over an empty tree, which is what puts a ws rm'd workspace back under the same id. - A deletion nothing has open is forgotten here too, instead of haunting the switcher until a restart. - Workspace::attachment travels over the wire (minus the token that proves the hold, which stays on the connection that owns it) and is stripped in persist, so tty7 ls can name the host holding a workspace. - tab ls / ws tree fall back through name -> agent -> cwd leaf -> process name, and tab ls grew a read-only GROUP column. - tty7 new --open raises a window on the workspace it just made. |
||
|
|
5e6bc1246f |
fix(workspace): stop switching workspaces from destroying live sessions (#410)
* fix(workspace): stop switching workspaces from destroying live sessions Switching workspaces rebuilt every pane it was asked to restore, and a window that rebuilt nothing then deleted the workspace outright — tree and store both. Three separate guesses, each one authorizing an irreversible act: - `session_from_tree` erased a pane's id when the tree said `live: false`. That flag is a cached observation from another process, reloaded as false on every server start, so a quiet pane read as dead while its shell was running. The restore had nothing to attach to and spawned a fresh shell over it. - Two servers could start against one config dir. `run_with` decided another server was dead by failing to connect once, then unlinked its socket and bound its own. The loser kept `control.sock` with an empty pane registry, so `MachineGet` reported every pane dead and nothing logged an error. - `finish_hydration` marked a window informed before the rebuild and without looking at the result. `tabs_from_session` drops any tab whose panes all fail to start, which is every tab when the pane socket is unreachable — leaving a window that was empty and authoritative at once, and the next switch deleted a workspace with ten live tabs. Each is now settled by whoever holds the truth: attaching decides whether a pane is there, an advisory lock decides which process is the server, and a deletion needs the machine's own mirror to agree that the workspace is empty. * fix(state): quarantine a corrupt views.json instead of silently discarding it machine.json already sets a corrupt file aside before falling back to defaults; views.json just logged and returned None, and the next save overwrote whatever the file held. Move the quarantine helpers to config so both loaders share them. Also make the no-lock-primitive fallback in the daemon singleton report Unavailable rather than Taken, so a platform without flock still gets a server instead of one that refuses to start. * fix(restore): a failed List no longer reads as every pane being dead Review follow-ups on #410, all three the same shape the PR exists to stamp out: - alive_panes_on flattened a failed List RPC into an empty alive-set, which made pane_attachable respawn every pane in the batch over its running session. The failure now surfaces as None and the attach itself decides, the way session_from_tree already leaves it to. - pub fn run() bypassed the singleton lock entirely; it had no callers, so a future one would have silently reintroduced the split-brain. Removed. - Singleton::path() and the field behind it were unused. Removed. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
f1f632edd1 |
fix(daemon): notice a server that is only a control dialect behind (#387)
* fix(daemon): notice a server that is only a control dialect behind The pane protocol and the control dialect are versioned apart, and the launch check only compared the first. A server from before the v4 to v5 control bump answers the pane handshake with this build's own number, so it was waved through as ours while every machine-tree call was refused: the window opened with no tabs, the only trace a log line the default config does not write anywhere. Ask the control socket too, before calling the server ours, and say which version disagrees when offering the restart. A window that still opens empty now says why in the window it happened to. * fix(daemon): do not call a newer server old, and say the empty-window reason once The dialect handshake reports disagreement, not direction, but the restart prompt read every mismatch as "from an older build". A daemon left running by a newer build passes the pane-protocol check the same way an older one does, so the prompt told users their newer server was old — the same wrong guess the remote path stopped making in #384. Split the copy in two and pick by which side is ahead, as `dialect_complaint` already does for remotes. The window's own explanation had two ways to misfire. A hydration superseded by a newer one still announced an emptiness someone else was already filling, because only `owe_rehydration` checked the epoch; it now reports whether it claimed the debt and only the owner speaks. And since a failed pull is retried from every `sync_window`, a machine that never answers repeated the same notification every fifteen seconds — latched to once per window, cleared when a pull finally lands so a later outage still gets a word. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
b9956ad6af |
feat(switcher): two-column panel with IDEA-style Ctrl+Tab (#380)
* feat(switcher): two-column panel with IDEA-style Ctrl+Tab
The switcher listed workspaces only; reaching a tab inside one meant
opening it first. It is now two columns — workspaces on the left, the
tabs of whichever one the cursor sits on to the right — and Ctrl+Tab
raises it as a most-recently-used tab switcher that commits when the
modifier comes up.
Picking a workspace or a tab now switches this window in place. A second
window is something you ask for, with the platform modifier or "Open in
New Window", rather than what happens by default.
New workspaces get a codename ("amber-yak") instead of inheriting
whatever directory their first shell started in. The generator moves out
of worktree.rs into core::codename so both callers share it.
* fix(switcher): review fixes — platform-gate the Ctrl+Tab key test, park the new-window tab
- The key_intent test asserted only the macOS half of the Tab chord; off
macOS Ctrl *is* the secondary modifier, the raw key falls through on
purpose (the chord arrives as NextTab), and the test now says so
instead of failing there.
- open_at_tab probed a freshly opened window's tabs exactly once, but a
new window hydrates them asynchronously — route through
activate_tree_tab so the pick parks until the tab arrives.
- Escape mid-rename backs out of the rename instead of tearing down the
whole panel.
- A one-tab workspace reads '1 tab', not '1 tabs'.
Claude-Session: https://claude.ai/code/session_01V7xjxdUGWQp93KQDuFuh8d
* style: cargo fmt
Claude-Session: https://claude.ai/code/session_01V7xjxdUGWQp93KQDuFuh8d
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
|
||
|
|
6374a08621 |
fix(ui): restarting the server must not strand the window on the home page
Restarting the local server clears the window's tabs and then pulls the layout back from the machine tree. The pull went out on the control link we held, which pointed at the server we had just killed: `is_connected` only flips once the reader sees EOF, so for a moment the dead link still classifies as Ready. The call on it failed, `finish_hydration` logged a warning and gave up, and the window stayed empty on the home page. Drop the local link before resyncing so the pull waits for the new server instead of racing a dead socket, and record a failed hydration as a debt the next sync retries. The debt also stops the empty window from diffing into "close every tab" and wiping the layout off the machine once the link is back; a `Replace` retry is abandoned if the user has filled the window in the meantime. |
||
|
|
8c1946d763 |
chore: strip every comment from the Rust sources (#268)
Removed all Rust comments -- line, block, and doc -- from the 139 tracked .rs files with `uncomment` 3.5.1. It parses each file with tree-sitter instead of matching text, so comment-like content inside string literals is left alone: the JavaScript plugin source embedded in agent_hooks.rs raw strings keeps its own `//` lines. Left alone: Cargo.toml comments and the shell scripts under scripts/. Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> |
||
|
|
a4972d32d8 |
feat(core): daemon-owned workspace tree — semantic ops, incremental deltas, thin clients (#260)
* refactor(daemon): share one run_daemon between tty7 and tty7-server
Extract the control-listener-plus-pane-server startup from tty7-server
into tty7_core::daemon::server::run_daemon, and point both binaries at
it. The local daemon now serves the control dialect exactly like a
remote one: one machine = one daemon, whichever binary happens to be
running it.
The bound control socket (and a bind failure) is still reported on
stderr with the historical 'tty7-server:' prefix — a headless server's
log file is off by default, and the remote_router test reads that exact
line back to prove the client derivation and the server bind agree.
* feat(core): daemon-owned machine tree with semantic operations
Add core::machine: the workspace/tab/pane tree a machine's daemon owns
outright, replacing the client-owned-schema model of the opaque record
store. Leaves hold a pane id and nothing else; every fact about a pane
(cwd from OSC 7, title, ssh spec, agent identity) lives once in the
pane registry, which is what makes revival sound: a reopened store
force-clears every live flag, so after a daemon restart the tree itself
says every leaf is awaiting revival — no client-side instance stamps or
id-reuse heuristics required.
Operations (workspace create/rename/delete/touch/set-active-tab, tab
create/close/rename/move/regroup, pane split/close/set-ratio/move/
replace) validate against the held tree, persist atomically, roll back
on a failed write, and broadcast incremental LayoutDelta events with
origin exclusion so a writer never hears its own echo. Persisted to
machine.json beside the old store's file, serde with #[serde(default)]
throughout so the daemon can keep evolving the schema, corrupt files
quarantined instead of overwritten.
* feat(control): machine-tree verbs and incremental Layout deltas
Teach the control dialect the semantic operations the machine tree
serves: MachineGet / WorkspaceTree pulls, WorkspaceCreate / Rename /
Remove / Touch / SetActiveTab, TabCreate / Close / Rename / Move /
SetGroup, and PaneSplit / Close / SetRatio / Move / Replace. Replies
carry the daemon's own tree types (a created workspace or tab comes
back whole; close operations answer the pane ids that left the tree so
the caller can kill their PTYs), and every operation broadcasts a
ControlEvent::Layout delta to every connection but the writer's — the
same origin-exclusion mechanism the record store uses, one delta at a
time instead of whole-record last-writer-wins.
The server advertises a new 'machine-tree' capability bit only when it
actually carries a MachineStore; both daemons now do, alongside the
retired opaque record store, which keeps serving unchanged while
clients migrate. Delta fan-out rides its own bounded queue and
forwarder thread per connection, so a peer that stopped reading stalls
nobody's edit; the drop-on-overflow tradeoff is documented against the
keepalive that reaps such a peer and the full pull every reconnect
starts with.
The request/reply/event enums lose their Eq derive: split ratios are
f32. End-to-end tests drive the shipped tty7-server binary over real
pipes: capability advertisement, tree ops landing in the server's own
file, dead-pane revival across a real process restart, and delta
delivery between two live clients.
* feat(daemon): pane facts flow from the pane server into the machine tree
The tree's pane records are only worth reviving from if they hold what
the machine itself observed, so the pane server now publishes into the
MachineStore the daemon serves: the reader thread reports OSC 7 / probed
cwd changes and the sniffer's agent facts (identity, native session id,
launch argv, coarse status) after each chunk that changed them, and
DeathReporter::report flips the record to live == false however the
death was noticed — that flag is the client-visible 'awaiting revival'
state, and it now comes from the process that owns the PTYs on the very
event, not only from the next restart.
The store rides a process-wide slot (installed by control_services,
same shape as the control event observer) so the three pane-spawn paths
need not thread it through; without one installed, observing is a
no-op, which keeps unit tests and tree-less servers quiet. Facts are
published outside the pane state lock and only on a real change, so the
reader's hot path pays two clones and a compare. AgentFacts.status
tightens from a free string to the existing AgentStatus enum while no
wire client depends on it.
* feat(ui): hold a supervised control link to the local daemon
The GUI now dials this machine's own daemon over the control dialect,
exactly as it does a remote one: one machine, one daemon, one control
link. The link lives in its own global rather than RemoteConnections —
inserting it there would register a wire-backed Host for this machine
(local files and git must keep going through the in-process LocalHost)
and would break the HostId::LOCAL-never-holds-a-control-connection
invariant. No routing either: the daemon's control socket is right
here, so connecting is a Unix connect plus a ControlHello.
Supervised on its own forever loop at the remote pump's cadence,
because that pump deliberately parks when the last remote workspace
closes and a purely local session is the common case. Each turn also
drains the shared control-event queue, so local pushes (Layout deltas,
Preempted) are delivered under HostId::LOCAL even with the remote pump
stopped; the observer install is shared with the remote supervisor so
whichever comes up first, reader threads never find nobody listening.
Reconnects ride the same 1/2/4/…/30s backoff a remote machine gets,
with ensure_running first — the daemon is the GUI's own child, and a
cold start legitimately races its listener.
Unix-only like the control listener it dials; on Windows the loop
compiles to a supervision no-op and the pane path is untouched.
* feat(control): attachment and takeover ride the machine tree too
WorkspaceAttach / WorkspaceDetach (and the hello-names-a-workspace
shorthand) now record their data half on whichever workspace stores the
server carries: the retired record store, the machine tree, or — on a
full daemon while clients migrate — both, since they describe the same
workspace. The behavioural contract is untouched and now survives the
record store's retirement: newcomer always wins, the displaced session
is pushed Preempted (and closed only when its link was dedicated), and
a preempted session's tidy-up detach cannot evict the usurper — the
token check lives in the tree's runtime-only attachment exactly as it
did in the store's. A server carrying neither store answers the same
refusal a store-less server always has.
WorkspaceId gains FromStr (the inverse of its Display) because the
attach verbs predate the typed tree and carry the id as a string. The
end-to-end test drives a takeover on a server serving the tree and no
record store at all, asserting the tree's own attachment record moves
with it.
* fix(core): review hardening for the machine-tree foundation
Findings from a correctness review of the new daemon-owned tree,
applied together:
- A dead pane can no longer be resurrected in the tree by its own last
output. On Windows the exit monitor reports the death while the
reader is still draining ConPTY's buffered bytes, and the death
report is latched; the reader's 'output is proof of life' publish now
asserts liveness only while the pane state still says alive.
- Delta delivery is ordered. Mutations were serialized by the state
lock but delivered after releasing it, so one writer's deltas could
overtake another's and leave every mirroring client on the losing
state with no cue to re-pull. A notify-order mutex now spans each
mutation and its own fan-out; cheap, because subscriber callbacks are
enqueue-only by contract.
- Implicit active-tab changes broadcast. tab_create's activation and
the close paths' heal now emit ActiveTabChanged, so a client applying
deltas never re-implements the server's heal rule; the one
inexpressible case (no tabs) needs no delta because it is a fact,
not surgery.
- The coarse agent status no longer drives disk writes: it flips per
hook event and is display-only, so it is outside the changed-facts
gate and merely rides along when a load-bearing fact changes.
- control_services reports which stores it serves on stderr again —
tty7-server configures no log sink, and 'no machine tree' was
invisible exactly where it matters, on a headless box.
- The local link's first connect attempt is immediate instead of one
backoff step late; the observation-slot test withdraws its store so
it cannot swallow later tests' observations; and locked()'s poison
rationale now says what is actually guaranteed.
* feat(control): let clients mint workspace and tab identities on create
A window names its workspace — in the registry, the view file, and any
operation it queues — before its first round trip completes, and the same
holds for a tab the moment the user opens it. Making the daemon the only
minter would force every client to hold its edits until a reply carried
the real id back. Ids are uuids, so a client-minted one is as unique as a
daemon-minted one; WorkspaceCreate and TabCreate now carry an optional
client id, keep it when it is free, and refuse a duplicate rather than
adopt it. Absent (older callers, tests) the daemon mints as before.
* feat(ui): windows speak semantic tree operations for every structural change
The write path of the client migration: each window now keeps a mirror of
what the daemon's tree holds for its workspace, and save_session — the
funnel every structural change already passes through — diffs the window
against that mirror and sends the recovered operations (TabCreate,
PaneSplit, PaneClose, PaneReplace, TabMove, ratio and label ops) over the
workspace's control link: the LocalLink for this machine, the machine's
RemoteConnections entry otherwise. Consecutive saves differ by exactly one
user action, so the diff recovers that action rather than re-shipping the
layout; changes no single op expresses rebuild the affected tab whole,
matching the delta contract's own granularity.
The mirror advances by running the server's own tree surgery (PaneNode's
split/remove/replace are public now), and any disagreement — a refused op,
a dropped link — resolves by one shared recovery path: drop the queue,
re-pull WorkspaceTree, re-diff. Fresh spawns are invisible until their
pane id lands; land_pane's save is when their create goes out. GUI tabs
carry a client-minted TabId, and a primed mirror re-points tabs it
recognizes by their panes, so a rebuilt window adopts the daemon's tabs
instead of churning them.
Workspace-level facts ride along: focus touches, renames, and deletions
now reach the machine's tree too, and the divider drag finally persists
the ratio it lands on (it previously reached disk only as a passenger on
the next structural change).
session.json is still written in parallel; it retires with the read-path
migration.
* feat(ui): local windows restore by asking the daemon's tree
The read path: opening a known local workspace no longer rebuilds from
session.json synchronously. The window opens empty and a background pull
(MachineGet — the workspace's structure joined with the pane registry,
which is where the revival facts live) rebuilds it the moment the daemon
answers; against the local daemon that is milliseconds, so the empty
state is effectively one frame — the same shape a remote workspace's
connect-driven rebuild has always had.
The lowering from tree to window is the revival decision: a leaf whose
pane record says live re-attaches by id, a dead one lowers to an id-less
leaf carrying the record's cwd, SSH spec and agent resume — the exact
shape that makes the existing builder spawn a successor and type the
agent's --resume. The save that follows diffs the successor against the
mirror and sends PaneReplace, spending the old record; revival needed no
op code of its own.
Restored tabs keep their daemon tab ids (SessionTab grows a never-
persisted tree_id), so the first save addresses the daemon's tabs instead
of churning them. A tree with nothing for the workspace falls back once
to the client's cached layout, whose adoption re-populates the tree
through the ordinary diff — the whole of the best-effort import.
* feat(ui): live windows apply the machine's incremental layout deltas
The pump's event drain now lands ControlEvent::Layout instead of debug-
logging it: each delta advances this client's mirror (by the same
surgery the server ran) and then the live window showing the workspace —
renames, regrouping, moves, active-tab changes and ratio drags in place;
TabCreated by building the tab and attaching its (writer-spawned, so
live) panes; TabRestructured by rebuilding the one tab while reusing the
views of panes the window already shows, because re-attaching a pane
this window holds would steal its own stream. Origin exclusion means
every delta arriving is another client's edit, and applying it to window
and mirror in one step leaves the next local diff with nothing to echo.
A delta that will not apply cleanly — a tab the mirror never heard of, a
drifted window — falls back to re-pulling the workspace and rebuilding
the window from the authoritative tree, the same single recovery path
every other failure already uses.
* feat(daemon): report panes the machine tree no longer references
With the tree now populated by clients' semantic operations, the daemon
can finally see panes nothing references. A periodic sweep reports them —
log-only, deliberately: an unreferenced pane is not proof of a leak (a
native-SSH pane opened inside a remote workspace's window runs in this
daemon while belonging to the other machine's tree), and reclaiming one
wrongly kills a session the user is looking at. The sweep's interval
doubles as a grace period: a pane is reported only after being
unreferenced across two consecutive looks, so an adoption still in
flight is never flagged. Reclamation can be layered on once the log has
shown the false-positive rate is zero.
* feat(ui): remote workspaces read and write the machine tree like local ones
Local and remote are now the same shape end to end. A remote workspace
opens empty unconditionally (connected or not) and is filled by the same
tree hydration a local window uses; the connect supervisor's landing
replaces the opaque-record refresh with it — a blinked link relinks the
pane streams and hydrates whatever opened empty meanwhile, a replaced
server process resyncs the window from the tree, whose force-cleared
live flags are what make every leaf revive. The remote picker lists
workspaces from MachineGet, deriving names from the tree the way a
local workspace derives its own; creating one lets the hydration's
WorkspaceCreate mint it on the machine; the record push, pull, refresh
(WorkspaceChanged) and remote delete paths are gone client-side.
Windows that have not yet seen their machine's tree sync additively: a
window that opened empty ahead of its pull may add tabs but never prunes
ones it has not displayed, so its ignorance can no longer read as 'close
everything' — the diff takes an explicit scope, and only hydration (or a
deliberately authoritative open, like restore-off) grants the full one.
* refactor(core): retire the client-side pane-identity defenses
The machine tree made this whole family unnecessary, so it goes rather
than lingers: daemon_instance stamps (a restarted daemon's tree says
live=false about every pane — a fact, where the stamp was a heuristic),
forget_stale_pane_ids on both layers, dedupe_pane_ids (the daemon
refuses a pane appearing twice in its tree, so there is no duplicate to
mop up client-side), the claim/record instance plumbing, and the
whole-record halves of the storage split (to_remote_json,
apply_remote_json, REMOTE_OWNED_FIELDS, CLIENT_OWNED_FIELDS, and the
store's apply_remote / remote_payload), together with their tests.
forget_pane_ids stays for now: it clears the client's cached copy, which
still serves as the one-time import fallback until the view file slims
down to pure view state.
* refactor(ui): a local daemon restart rebuilds from the tree too
The tree file survives the restart and the fresh daemon force-clears
every pane's live flag, so the resync path already expresses exactly
what the hand-rolled saved-session rebuild did: every leaf revives as a
fresh shell in its recorded cwd with its agent resumed. The pull waits
out the local link reconnecting to the fresh daemon.
* docs(core): drop a stale reference to the retired record verbs
* fix(ui): close the review findings on the tree migration
Review fixes, worst first:
- Pane ids never alias across daemon restarts: the pane registry seeds
its counter past everything the persisted tree references. A fresh
process minting from 1 handed new shells ids that dead leaves still
claimed — the tree marked the wrong pane live, revival stalled forever
on 'already part of this machine's tree', and an attach by the stale
id stole another workspace's stream. Ids are names now, not slots.
- An empty window only licenses WorkspaceRemove once it is *informed*:
a window whose hydration has not answered is empty because it is
waiting, and closing or swapping it mid-pull was deleting populated
trees. Remote workspaces also hydrate regardless of the restore
setting — their panes are running sessions, not a saved layout, and
the restore-off swap used to open them empty-and-authoritative and
close every tab on the machine.
- Tabs whose panes are all still spawning are *held*, not pruned: they
are invisible in the desired tree without being absent, and the Full
diff was closing them (spending the records the landing spawns'
PaneReplace needed) on every remote revival.
- A preempted window stays passive under deltas: applying the usurper's
TabCreated/TabRestructured attached to their fresh panes and stole the
streams they were typing into. The mirror is dropped instead; taking
the workspace back re-pulls it whole.
- Delta TabClosed tracks the active tab by identity (closing a tab to
the left no longer shifts focus and pushes the wrong active tab back).
- The hydrate/resync path drops the op queue like desync does, so ops
computed against an abandoned mirror cannot drain after the snapshot.
- A rebuilt remote tab no longer matches a native-SSH leaf's *local*
pane id against remote ids; delta-applied ratios clamp to the GUI
band; async completions use get_mut so a forgotten window's sync state
is not resurrected.
* feat(ui): a per-machine mirror of each daemon's tree feeds the read surfaces
The switcher, the Window menu, the title bar, the rename seeds, the
stop/delete confirmation and the liveness sweep all answered their
questions (display name, subject path, pane ids, pane count) from the
client's cached copy of the layout. The machine's tree owns the layout
now, so a new per-host MachineMirrors global holds each machine's last
pulled tree — filled by a MachineGet whenever a control link comes up
(and for free off every hydration, which already pulls the whole
machine), advanced by the same Layout delta stream the windows consume,
plus explicit notes for this client's own operations, which origin
exclusion keeps out of that stream.
The readers move over wholesale. A machine not pulled yet reads as
not-knowing rather than a stale guess: pickers show the shared fallback
for a beat (against the local daemon the pull lands within a frame),
and the pane-count prompt says the machine could not be asked instead
of counting against a cache. tree_display_name moves out of the remote
picker into the mirror as display_name_of — it was always the tree
flavour of Workspace::display_name, and now everything shares it.
This is the read-model half of retiring the client's layout cache; the
persistence shrink to pure view state follows on top of it.
* refactor(ui): client persistence shrinks to pure window views
The client file stops carrying layout. session.json's Workspace — id,
name, a whole embedded Session, geometry, open, last_active, host —
becomes WindowView { id, window, open, last_active, host } in a fresh
views.json (no migration by design; an old session.json is simply
ignored, and its panes revive from the machine tree like any daemon
restart). Everything the embedded layout used to answer already moved
to the per-machine mirror, so this deletes the write half:
- WorkspaceStore::claim answers only the id; record shrinks to
record_geometry. claimable_session / record_session — the
reachability-gated layout cache — go entirely, and with them the
one-time empty-tree import in finish_hydration: with no cached copy
there is nothing to import, and the machine answering "no tabs" is
the layout.
- The user-set name is purely the machine's fact now. rename /
rename_locally leave the store; the chip and switcher renames fire
WorkspaceRename directly (tree_sync::rename_workspace), the
WorkspaceRenamed delta needs nothing from the window because the
mirror already applied it, and WorkspaceCreate seeds no name.
- forget_pane_ids / blank_pane_ids and the layout-derived getters
(display_name, dominant_repo, first_cwd, pane_count, pane_ids) are
deleted with their tests — each had grown a mirror-side twin.
- switch_workspace always hydrates: with the tree as the only layout
source, restore-off governs what launch comes back to, not what a
deliberate switcher pick shows.
The retired opaque record store loses its one test that asserted its
file parses as a client Workspaces document — that coupling is the
thing this migration ends, and the store itself is next to go.
* refactor(server): retire the opaque workspace record store
Clients stopped sending WorkspaceList/Get/Put/Delete when the tree
migration landed, so the coexistence scaffolding comes out:
- core::workspace_store is deleted. Attachment and the data-directory
resolution (TTY7_DATA_DIR, XDG fallback chain) move into
core::machine, which was already their only consumer; Attachment
loses its vestigial serde derives (it never crosses disk or wire).
- The control dialect drops the four record verbs, the ReplyOk::Json
payload they answered with, and the WorkspaceChanged event. Their
serde names (and the workspace-store capability bit) are recorded as
burned rather than reserved by any mechanism — the dialect has no
numbered slots to hold, so a comment at each site is the guard, plus
the handshake test asserting the bit never reappears.
- host::server loses Services.workspaces, the verb arms, the
per-connection store subscription and its WorkspaceChanged forwarder,
and the store half of attach/detach/teardown. Attachment data now
lives solely in the tree: a workspace the tree does not list records
no data half (the registry's live handles still move, so takeover
behaviour is unchanged), and it appears the moment the workspace
does. Services::with_workspaces/and_machine collapse into
with_machine; control_services becomes a single match.
- The attach/takeover tests move onto MachineStore wholesale, attaching
to workspaces created in a real tree; the record-store round-trip and
fan-out tests go (tests/machine_tree.rs has carried the tree
equivalents since the verbs landed), and tests/workspace_store.rs is
deleted with the serde_json dev-dependency that existed only for it.
machine.rs gains the two guarantees the old suite held uniquely: an
attachment dies with its workspace structurally, and the default path
resolution ends at the documented file.
- The GUI's dead WorkspaceChanged arm and every stale doc reference go.
* refactor(ui): rename RemoteConnections to HostLinks
Purely mechanical, plus the doc sentences that carry the model: the
table holds one control link per machine, and the local machine is a
machine like any other — its link just lives in its own global
(LocalLink) because it is in-process rather than wire-backed. The old
name framed the table as remote-only plumbing, which the tree
migration made false in spirit: local and remote windows speak the
same operations over whichever link their machine answers on.
* fix(ui): a tree-driven tab rebuild keeps the native-SSH split it cannot name
A native-SSH pane opened inside a remote workspace's window runs in
this client's own daemon and is deliberately absent from the remote
machine's tree (its local id would collide with an unrelated remote
pane). The TabRestructured rebuild therefore had no leaf for it and
dropped its view on the floor: the local session kept running,
invisible from every surface — a true orphan only the daemon's log-only
sweep would ever mention.
The rebuild now sets such leaves aside while harvesting reusable views
and appends each back as a fresh half-and-half split on the right once
the tree's own panes are built. The old split geometry is unknowable
from the delta (the tree never held it), so the appended shape is the
one a split created it in; the next save changes nothing, because the
diff already lowers a remote window without its ssh leaves.
The resync path (a delta that fails to apply, a replaced server) still
rebuilds the whole window from the tree and drops such views — that
path discards every view it has by design, and is left as a known
residual. TerminalView grows a test-only ssh-marked pane constructor so
the kept-split property is pinned by a gpui test.
* docs(core): finish pointing the last session.json references at views.json
* fix(ui): kick every local window's sync when the local link comes up
A window built while the local control link was still dialing parks as
Unprimed { dirty } — start_prime's unreachable arm leaves the retry to
"the reconnect-triggered save", but the local link supervisor never
triggered one. On a first launch (window built before the auto-spawned
daemon binds its socket) nothing else re-enters sync_window until the
next structural change, so quitting before one loses the window's
layout: the machine never heard of it.
Reproduced end-to-end on a scratch daemon: fresh launch, no user
action, quit — the relaunch came up empty. With the link supervisor
calling tree_sync::on_link_up on connect, the same launch syncs the
tree within one pump tick.
* fix(ui): read a deleted workspace's kill list before the removal blanks the mirror
delete_workspace fired WorkspaceRemove first, and fire_workspace_op folds
the removal into the machine mirror synchronously on its way out — so the
kill list stop_workspace_keeping then read off that mirror was always
empty, and 'Delete Workspace' ended zero of the sessions its confirm
prompt promised to end. The kill list is now read before the op fires,
and both destructive paths receive it explicitly so the ordering is a
signature rather than a convention.
* fix(control): bump both dialect versions and gate tree verbs on the machine-tree bit
The tree migration deleted four control verbs and added seventeen, but
CONTROL_VERSION stayed at 2 — two builds that cannot understand each
other's requests would have shaken hands as equals. It is now 3, with
the history entry the file's format asks for.
PROTOCOL_VERSION moves to 4 for the service change underneath: a
pre-tree 'tty7 --daemon' has no control listener at all, so a GUI from
this build silently adopting one connects its control link into the
void forever and every window hydrates from a tree that never answers.
The bump routes that meeting into ensure_running's existing
keep-or-restart prompt.
Clients now also consume the machine-tree capability bit before any
tree traffic: a connected peer without it (a server with no home
directory keeps serving files and panes) classifies as a distinct
'unserved' state that is logged once and skipped, instead of a refused
round trip per operation.
* fix(ui): preempted windows stay passive and take-back rebuilds from the tree
Two halves of the same takeover contract were broken.
A preempted window kept pushing: sync_window had no preemption check, so
a click on the read-only tab strip sent WorkspaceSetActiveTab against
the usurper's session, and the next save Full-diffed the stale layout —
rolling the usurper's edits back wholesale. sync_window now returns
early for a preempted workspace, and preemption itself drops the
window's queue, mirror and 'informed' licence (tree_sync::on_preempted,
shared with the delta path's existing reset).
Take Back never rebuilt: the recovery attach ran the ordinary IfEmpty
hydration, which skips any non-empty window — and a preempted window is
by definition non-empty with the pre-takeover layout. retry_now now
marks the workspace as reclaiming, and finish_attempt rebuilds marked
(or still-preempted) windows via Adopt::Replace, honouring the 'take
back re-pulls whole' promise the delta path documents.
* fix(ui): delta application survives pulls in flight
Three overlap bugs between the incremental delta stream and the full
pulls it has no ordering barrier with:
- A TabCreated straddling a pull was applied by both — the snapshot
already carried the tab, and the delta inserted a second copy into
the machine mirror and the window mirror, and rebuilt a second GUI
tab whose attach stole the pane's single stream from the window
itself. All three application sites now replace by id.
- A delta arriving while a window's prime/hydration was in flight was
applied to the window even though the mirror side skipped it — a
TabCreated landing in a still-empty window made finish_hydration
read 'the user got here first' and skip adopting the tree, leaving
the window with only the concurrently-created tab forever. Window
application is now gated on the mirror being primed; the pull's
snapshot carries the delta's effect.
- A prime answered after a newer cycle (hydration, desync, preemption)
replaced it would install its stale tree over a mirror that had since
advanced, and the next diff would re-emit the rollback as operations.
Every cycle now stamps an epoch, and pulls landing under an old one
are dropped.
* fix(ui): apply ratio deltas in the server's clamp band
set_gui_ratio clamped to 0.1-0.9 while the server accepts 0.05-0.95, so
another client's 0.07 arrived as 0.1 — and the next save's ratio diff
pushed the rewrite back at the machine, silently moving their divider.
* fix(core): machine-store hardening around seeds and unreadable files
- A PaneSeed entered the registry live:true unconditionally. A pane
that died between its spawn and its adopting operation had its death
observation dropped (note_pane_facts ignores panes the tree does not
hold), and nothing ever flipped the record back — the leaf claimed a
live pane forever and revival was never offered. The daemon now
installs a liveness probe on the store (registry-backed), consulted
at registration; without one (tests, clients) the seed is trusted.
- seed_ids_past computed max + 1, which panics a debug daemon at
startup when the persisted tree names u64::MAX. saturating_add parks
the counter at the ceiling instead.
- load_machine quarantined an unparseable file but not an unreadable
one: a read failure logged, started empty, and the first mutation
overwrote the very file that could not be read. Read failures now
quarantine too — by rename, since a copy would need the read
permission that just failed.
Also de-flakes the pre-existing spawn_writer test: the first write into
a freshly-closed socket can succeed before the kernel processes the
close, so the poll loop now keeps the writer fed until a write fails.
* feat(control): announce dropped layout deltas so lagged clients resync
A connection whose per-link delta queue overflowed lost an edit it will
never hear again — the server logged the drop, and the client mirrored
a tree it was no longer looking at until something else happened to
fail. The subscriber callback now flags the connection lagged, and the
layout forwarder sends the new ControlEvent::LayoutResync ahead of the
next delta it delivers (the flag is only ever set with a full queue
behind it, so the announcement never waits on a quiet tree). The client
answers by re-pulling the machine mirror and resyncing every window on
that machine — the same recovery an unappliable delta already uses,
announced instead of stumbled into. WatchOverflow is the precedent.
* fix(ui): a pure native-SSH tab is invisible to the tree, not held forever
Held means 'spawns are landing, wait before ordering' — but a remote
window's tab that is native-SSH through and through can never land: its
panes live in this client's daemon and are deliberately unnameable in
the remote machine's tree. Filing it as held made every diff return
before the ordering and active-tab passes, freezing tab order and
activation sync for the whole window for as long as the tab existed —
and a mixed tab whose last remote pane was closed kept its dead leaf on
the machine for ever, because the held id shielded the daemon tab from
the close.
Such tabs are now classified permanently invisible: not desired, not
held. Ordering resumes, and the mixed tab's daemon twin closes when its
last tree-visible pane goes. Pending leaves (a connecting spawn, an
empty slot) still read as held.
* docs(core): drop the dead instance helper, the stale title field, and two doc lies
- local_daemon_instance() lost its last caller when the client-side
pane-identity defenses were retired; deleted.
- DaemonVersion::instance's doc pointed at Workspace::daemon_instance
(deleted with the record store) and claimed pane ids restart from 1 —
no longer true of a tree-carrying daemon, which seeds its ids past
everything the tree names. Rewritten to describe what the field
actually backs now.
- PaneRecord::title claimed to label panes awaiting revival, but no
code ever wrote it: the pane's title is a live foreground-process
query at PaneInfo time, not state the facts path observes. The field
is deleted (serde-compatible: unknown fields are ignored on read) and
the decision recorded where it lived; revival labels derive from cwd
and agent.
* fix(ui): converge the tree after adopting a delta-created tab
Adopting a TabCreated delta whose pane is dead on arrival attaches
nothing and spawns a fresh pane under a new id — and nothing on the
delta path saved afterwards, so the tree kept the dead leaf: other
clients saw a dead tab, and a relaunch would spawn a second successor
beside the leaked first. Reproduced end-to-end (external client creates
a tab with an unspawned pane; the GUI adopted it and the tree never
learned the successor's id).
One sync_window after a clean apply closes it: free when window and
mirror agree (the diff is empty), and exactly the PaneReplace that
spends the dead record when adoption had to spawn.
* fix(core): review follow-ups on the daemon-owned tree
Nine findings from a review pass over the branch. One commit because
they cross the same files, and splitting them would leave an
intermediate that does not build on Windows.
- A dropped delta announced a LayoutResync and then delivered the
backlog behind it. The queue is FIFO, so everything still in it is
*older* than the gap: the peer re-pulled on the notice and was then
walked back through history it had already left — TabRestructured
restoring the shape a tab used to have, with window and mirror
agreeing on the stale answer so nothing recovered a second time. The
forwarder now drops the superseded queue and sends the resync in its
place.
- Pane facts persisted the whole document, with an fsync, from the PTY
reader thread — once per OSC 7, so once per prompt per pane — while
holding the lock that orders every other client's edits. A shell
looping over directories was a write per iteration. Observations
(pane facts, workspace_touch) now take Persist::Soon: the delta still
goes out at once, the file catches up within FACT_FLUSH_INTERVAL, and
the daemon flushes on the way out. The layout itself is never
deferred.
- An ordinary output chunk paid two AgentFacts clones and a
clone-to-compare for facts it could not have changed. Gated on the
signals that can move one, and the compare no longer clones.
- machine.json was created 0644, naming every workspace's directories,
the SSH user and host of every native-SSH pane, and each agent's
session id. It is written owner-only from the first instant the final
name exists, and a second corruption no longer overwrites the rescue
copy of the first.
- Windows had no control listener, so on the one platform where the
tree is the only layout store, tabs did not come back at all. It now
serves the dialect over the transport its pane socket already uses: a
loopback listener whose port and 256-bit token live in a user-private
control.port beside daemon.port — its own token, not the pane
endpoint's — refusing to rebind over a live one, since binding is
what writes the marker. run_daemon and the GUI's local link are one
code path again.
- Workspace names and paths came only from the machine's mirror, so a
laptop shut since Friday listed every row as "Untitled" with a blank
subtitle, in the picker whose whole job is offering workspaces on
machines that are asleep. WindowView carries the label and subject
the machine last gave, stamped on save and on detach; the tree still
wins whenever it answers.
- liveness_of read "the mirror has not been pulled yet" as Stopped,
which tells the user their sessions are gone on the strength of our
own ignorance. Unknown is what that state is for.
- A WorkspaceRemove that never reached its machine was a debug line,
though the client had already forgotten the workspace. It is now a
warning that says what was left where.
- MachineMirrors::install landed a pull without a repaint; the two tests
the record store's retirement took with it (a closed connection stops
being a subscriber, concurrent connections can all write) are back
against the tree; and CHANGELOG records the migration's one-time
layout loss and the Windows gap this closes.
Suites green: tty7-core 675, tty7 819, tty7-server 9/5/3/3/51, fmt and
clippy clean. The Windows listener is unverified by a compiler here — a
C dependency in the tree blocks cross-checking from macOS — so CI's
Windows job is its first build.
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Co-authored-by: thomas <thomas@thomass-Mini.lan>
|