mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
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>
This commit is contained in:
co-authored by
l0ng-ai
thomas
parent
183b5e4950
commit
a4972d32d8
@@ -60,6 +60,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- **The machine that runs your panes now owns their layout** — the workspace,
|
||||
tab and pane tree has moved out of the app and into the background service, so
|
||||
one machine has one tree that every client of it reads: the window on it, a
|
||||
laptop connected to it across the world, and (next) the session CLI. Clients
|
||||
send named edits ("split this pane", "rename that tab") and receive the
|
||||
incremental changes other clients make, which is what lets two windows on one
|
||||
machine both land their work instead of the last one to save winning. A pane's
|
||||
working directory, its coding agent and whether it is still running are now
|
||||
observed by the service that owns the PTY rather than remembered by whichever
|
||||
client last wrote a file — so after a service restart every pane is *known*
|
||||
dead and revives into its recorded directory with its agent conversation
|
||||
resumed, with no guessing about which saved ids survived.
|
||||
|
||||
Two consequences worth knowing before you upgrade:
|
||||
|
||||
- **Saved layouts do not carry over.** The tree is a new file
|
||||
(`~/.local/share/tty7/machine.json`) and the old `session.json` is not read;
|
||||
the upgrade also replaces the background service, which ends the panes it was
|
||||
holding. The first launch after upgrading comes up on a fresh workspace, and
|
||||
tabs from before it are not recoverable. `views.json` (window geometry and
|
||||
which workspaces you had open) replaces `session.json` for the client's own
|
||||
half; the old file is left on disk, unread.
|
||||
- **Windows keeps its panes but not its layout, for now.** The tree is served
|
||||
over the same control channel remote machines use, and that channel is
|
||||
Unix-socket-only today, so on Windows tabs do not come back across a restart.
|
||||
Panes, splits, agents and shell integration are unaffected within a session.
|
||||
(#260)
|
||||
|
||||
- **The prompt editor's soft newline is now a rebindable action** — `Shift+Enter`
|
||||
and `Alt+Enter` have inserted a literal newline into the command editor since
|
||||
the multi-line prompt editor landed, but the chords were hardcoded in the key
|
||||
|
||||
Generated
-1
@@ -9606,7 +9606,6 @@ dependencies = [
|
||||
name = "tty7-server"
|
||||
version = "26.7.6"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tty7-core",
|
||||
]
|
||||
|
||||
@@ -951,7 +951,7 @@ pub fn default_config_dir() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// Resolve a file under the config directory (no `dirs` dep). Shared by every
|
||||
/// config-dir file (`config.json`, `session.json`, `history`).
|
||||
/// config-dir file (`config.json`, `views.json`, `history`).
|
||||
pub fn config_path(file: &str) -> Option<PathBuf> {
|
||||
Some(config_dir()?.join(file))
|
||||
}
|
||||
@@ -976,8 +976,30 @@ pub fn strip_bom(text: &str) -> &str {
|
||||
/// old file or the new one intact — never a truncated/half-written file that
|
||||
/// fails to parse and silently reverts the user's settings to defaults. The temp
|
||||
/// lives in the same directory so the rename stays on one filesystem (atomic).
|
||||
/// Shared by `Config::save` and `Session::save`.
|
||||
/// Shared by `Config::save` and `WindowViews::save`.
|
||||
pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
write_atomic_mode(path, bytes, false)
|
||||
}
|
||||
|
||||
/// [`write_atomic`], with the target owner-only from the first instant its final
|
||||
/// name exists.
|
||||
///
|
||||
/// The mode is set on the *temp* file, before the rename, for the same reason
|
||||
/// [`bind_control_socket`](crate::host::server::bind_control_socket) tightens
|
||||
/// the umask around its `bind` rather than chmod-ing afterwards: a fix-up on the
|
||||
/// next line is a window in which the file is readable, and under a `umask 002`
|
||||
/// — the default wherever user-private groups are configured — that window is
|
||||
/// group-readable. For documents whose contents are the user's business alone:
|
||||
/// `machine.json` names every workspace's directories, SSH users and hosts, and
|
||||
/// agent session ids.
|
||||
///
|
||||
/// A no-op difference on Windows, which has no mode bits: the config directory's
|
||||
/// own ACL is the boundary there, as it is for the daemon's port file.
|
||||
pub fn write_atomic_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
write_atomic_mode(path, bytes, true)
|
||||
}
|
||||
|
||||
fn write_atomic_mode(path: &std::path::Path, bytes: &[u8], private: bool) -> std::io::Result<()> {
|
||||
use std::io::Write as _;
|
||||
let dir = path.parent().unwrap_or_else(|| std::path::Path::new("."));
|
||||
// Per-process-unique temp name so two concurrent writers don't clobber the
|
||||
@@ -989,7 +1011,16 @@ pub fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()>
|
||||
std::process::id()
|
||||
));
|
||||
{
|
||||
let mut f = std::fs::File::create(&tmp)?;
|
||||
let mut open = std::fs::OpenOptions::new();
|
||||
open.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
if private {
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
open.mode(0o600);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = private;
|
||||
let mut f = open.open(&tmp)?;
|
||||
f.write_all(bytes)?;
|
||||
f.flush()?;
|
||||
let _ = f.sync_all();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ pub mod crash;
|
||||
pub mod git;
|
||||
pub mod gitignore;
|
||||
pub mod logfile;
|
||||
pub mod machine;
|
||||
// SSH connection-manager data layer (WS1). Its public API is consumed by the
|
||||
// daemon-session, auth, forwarding, and UI workstreams, which land separately —
|
||||
// so parts of it read as dead code until those merge.
|
||||
@@ -30,5 +31,4 @@ pub mod shells;
|
||||
pub mod ssh_profile;
|
||||
pub mod threads;
|
||||
pub mod window_state;
|
||||
pub mod workspace_store;
|
||||
pub mod worktree;
|
||||
|
||||
+182
-1328
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
//! Persisted last-window geometry, stored at `window.json` in the config dir
|
||||
//! (alongside `config.json` / `session.json`). The quit hook in `ui::app`
|
||||
//! (alongside `config.json` / `views.json`). The quit hook in `ui::app`
|
||||
//! writes the window's final bounds here unconditionally; startup reads it
|
||||
//! back only when `Config::remember_window_size` is on, so toggling the
|
||||
//! setting off and on again still restores the most recent quit's geometry.
|
||||
@@ -7,9 +7,9 @@
|
||||
//! reads fall back to "nothing remembered", writes are atomic.
|
||||
//!
|
||||
//! The geometry is four plain `f32`s here rather than a `gpui::Bounds` because
|
||||
//! [`Workspace`](super::session::Workspace) embeds it and `session.json` has to
|
||||
//! parse without gpui. Converting to and from `Bounds` is the GUI crate's job —
|
||||
//! see its `core::window_state::WindowGeometry` extension trait.
|
||||
//! [`WindowView`](super::session::WindowView) embeds it and `views.json` is
|
||||
//! parsed in this gpui-free crate. Converting to and from `Bounds` is the GUI
|
||||
//! crate's job — see its `core::window_state::WindowGeometry` extension trait.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -90,11 +90,25 @@ use super::protocol::{MAX_FRAME, read_frame, write_frame};
|
||||
/// comparing dialect numbers, so a capability that doesn't move the number is a
|
||||
/// capability the far machine never gets. A [`feature`] string is the right
|
||||
/// answer only for something two current servers can genuinely disagree about
|
||||
/// (the workspace store, which depends on how the server was started); "this
|
||||
/// (the machine tree, which depends on how the server was started); "this
|
||||
/// build knows the request and older ones don't" is what the number is for.
|
||||
///
|
||||
/// ## History
|
||||
///
|
||||
/// - **v3** — the machine-tree migration. The workspace/tab/pane tree moved
|
||||
/// into the daemon: `MachineGet` / `WorkspaceTree`, the semantic tree verbs
|
||||
/// (workspace/tab/pane create, close, rename, move, split, ratio, replace),
|
||||
/// and the [`ControlEvent::Layout`] / [`ControlEvent::LayoutResync`] pushes
|
||||
/// — seventeen new request variants in all — while the retired opaque-record
|
||||
/// verbs
|
||||
/// (`workspace_list` / `workspace_get` / `workspace_put` /
|
||||
/// `workspace_delete` and the `workspace_changed` event) left the dialect
|
||||
/// entirely. A v2 peer meeting any of the new variants fails the whole
|
||||
/// decode (no `#[serde(other)]`), and this build meeting a v2 server's
|
||||
/// record verbs would answer unknown-variant errors forever. The
|
||||
/// [`feature::MACHINE_TREE`] bit still exists *within* v3, because two
|
||||
/// current servers can genuinely differ on it (a box with no home
|
||||
/// directory serves files but no tree).
|
||||
/// - **v2** — [`ControlRequest::Shells`], which backs a remote window's new-tab
|
||||
/// dropdown. Not a `feature` string: every server from this build on answers
|
||||
/// it, so the only thing a capability bit would have bought is that a machine
|
||||
@@ -102,7 +116,7 @@ use super::protocol::{MAX_FRAME, read_frame, write_frame};
|
||||
/// menu. The bump makes `RemoteProtocol::serves` refuse to adopt that server
|
||||
/// and install this build's instead, which is the actual fix.
|
||||
/// - **v1** — the dialect at the time remote workspaces landed.
|
||||
pub const CONTROL_VERSION: u32 = 2;
|
||||
pub const CONTROL_VERSION: u32 = 3;
|
||||
|
||||
/// This process's identity as a control server, minted once on first use.
|
||||
///
|
||||
@@ -184,10 +198,20 @@ pub mod feature {
|
||||
pub const CONTROL: &str = "control";
|
||||
/// Serves [`super::ControlRequest`]'s filesystem and git methods — i.e. can
|
||||
/// back a remote `Host`. Distinct from [`CONTROL`] because a peer could
|
||||
/// speak the dialect while exposing only the workspace store.
|
||||
/// speak the dialect while exposing only the workspace tree.
|
||||
pub const HOST_RPC: &str = "host-rpc";
|
||||
/// Serves the `Workspace*` requests.
|
||||
pub const WORKSPACE_STORE: &str = "workspace-store";
|
||||
// `"workspace-store"` is a burned name: it advertised the retired
|
||||
// opaque-record scheme (verbs `workspace_list` / `workspace_get` /
|
||||
// `workspace_put` / `workspace_delete`, event `workspace_changed`), all of
|
||||
// which are burned with it. Never re-advertise or re-mint any of them with
|
||||
// a different meaning.
|
||||
/// Serves the machine-owned workspace tree: the `MachineGet` /
|
||||
/// `WorkspaceTree` pulls, the semantic tree operations, and the
|
||||
/// [`super::ControlEvent::Layout`] pushes. Advertised only when the server
|
||||
/// actually carries a [`crate::core::machine::MachineStore`], so a client
|
||||
/// learns from the handshake whether the tree verbs are worth a round
|
||||
/// trip.
|
||||
pub const MACHINE_TREE: &str = "machine-tree";
|
||||
/// Can be launched as `--stdio` and bridge its own stdin/stdout to the
|
||||
/// machine-local socket (the fallback when `AllowStreamLocalForwarding` is
|
||||
/// off, the only option under WSL, and how the CI end-to-end test runs).
|
||||
@@ -209,6 +233,13 @@ pub use crate::host::{Entry, MTime, Meta, Output, SearchHit};
|
||||
// the wire, not a wire-only copy of it.
|
||||
pub use crate::core::shells::{DetectedShell, ShellInventory};
|
||||
|
||||
// And for the machine tree: the daemon's own tree types are the wire types, so
|
||||
// a schema drift between the store and the dialect is a compile error rather
|
||||
// than a silent mistranslation. `WorkspaceId` rides along because every tree
|
||||
// verb addresses a workspace by it.
|
||||
pub use crate::core::machine::{Axis, LayoutDelta, Machine, PaneSeed, Side, Tab, TabId};
|
||||
pub use crate::core::session::WorkspaceId;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Requests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -220,7 +251,8 @@ pub use crate::core::shells::{DetectedShell, ShellInventory};
|
||||
/// are routinely different operating systems. Remote paths are UTF-8 POSIX; a
|
||||
/// non-UTF-8 name on the server is returned lossily by `ReadDir`, matching what
|
||||
/// the file tree already does locally with `to_string_lossy`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
// Not `Eq`: the machine-tree verbs carry split ratios, and `f32` has no `Eq`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ControlRequest {
|
||||
// ----- liveness ---------------------------------------------------------
|
||||
@@ -328,18 +360,10 @@ pub enum ControlRequest {
|
||||
id: u64,
|
||||
},
|
||||
|
||||
// ----- workspace store (M5; the slots exist, the server doesn't yet) -----
|
||||
WorkspaceList,
|
||||
WorkspaceGet {
|
||||
id: String,
|
||||
},
|
||||
WorkspacePut {
|
||||
id: String,
|
||||
json: serde_json::Value,
|
||||
},
|
||||
WorkspaceDelete {
|
||||
id: String,
|
||||
},
|
||||
// The opaque record store's verbs — `workspace_list` / `workspace_get` /
|
||||
// `workspace_put` / `workspace_delete` — lived here until the machine tree
|
||||
// below replaced them. Their serde names are burned (see `feature`); do
|
||||
// not re-mint them with a different meaning.
|
||||
|
||||
// ----- attachment (M6's takeover) ---------------------------------------
|
||||
/// Claim a workspace for this connection's session, taking it over from
|
||||
@@ -363,6 +387,126 @@ pub enum ControlRequest {
|
||||
WorkspaceDetach {
|
||||
id: String,
|
||||
},
|
||||
|
||||
// ----- machine tree (the daemon-owned structure) ------------------------
|
||||
// The semantic replacement for the retired opaque record verbs: instead of
|
||||
// a whole-record `Put` (last-writer-wins the moment two clients write),
|
||||
// each operation names its edit, the server validates it against the tree
|
||||
// it owns, and everyone else hears an incremental
|
||||
// [`ControlEvent::Layout`]. Positions cross as `u64` for the same reason
|
||||
// `Search`'s limits do: a 32-bit server clamps rather than wraps.
|
||||
/// The whole tree — every workspace, tab and pane record on the machine.
|
||||
/// The full pull a client starts from before applying deltas.
|
||||
MachineGet,
|
||||
/// One workspace of the tree, whole. `NotFound` when the machine has no
|
||||
/// such workspace; also the re-pull a client falls back to when it cannot
|
||||
/// apply a delta.
|
||||
WorkspaceTree {
|
||||
workspace: WorkspaceId,
|
||||
},
|
||||
/// Create an empty workspace; its first tab arrives as its own operation.
|
||||
/// Answers the newborn [`ReplyOk::WorkspaceTree`]. `workspace` lets the
|
||||
/// client mint the id — a window names its workspace before any round trip
|
||||
/// completes — and `None` has the daemon mint one, as before. A taken id
|
||||
/// is refused, never adopted.
|
||||
WorkspaceCreate {
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
workspace: Option<WorkspaceId>,
|
||||
},
|
||||
WorkspaceRename {
|
||||
workspace: WorkspaceId,
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Forget a tree workspace and everything under it. Named `Remove` because
|
||||
/// `WorkspaceDelete` was the retired record store's verb, and its serde
|
||||
/// name stays burned.
|
||||
WorkspaceRemove {
|
||||
workspace: WorkspaceId,
|
||||
},
|
||||
/// Stamp a workspace as just-focused, for pickers ordered by recency.
|
||||
WorkspaceTouch {
|
||||
workspace: WorkspaceId,
|
||||
},
|
||||
WorkspaceSetActiveTab {
|
||||
workspace: WorkspaceId,
|
||||
tab: TabId,
|
||||
},
|
||||
/// Create a tab holding `pane` at position `at` (clamped; `None` appends).
|
||||
/// `pane` is the seed for a pane the client already spawned over the pane
|
||||
/// protocol — PTYs come from there, the tree only adopts them. `tab` is
|
||||
/// the client-minted identity (see `WorkspaceCreate::workspace`); `None`
|
||||
/// has the daemon mint one.
|
||||
TabCreate {
|
||||
workspace: WorkspaceId,
|
||||
at: Option<u64>,
|
||||
pane: PaneSeed,
|
||||
#[serde(default)]
|
||||
tab: Option<TabId>,
|
||||
},
|
||||
/// Close a tab. Answers [`ReplyOk::Panes`]: the pane ids that left the
|
||||
/// tree, for the caller to kill — the tree does bookkeeping, not process
|
||||
/// teardown.
|
||||
TabClose {
|
||||
workspace: WorkspaceId,
|
||||
tab: TabId,
|
||||
},
|
||||
TabRename {
|
||||
workspace: WorkspaceId,
|
||||
tab: TabId,
|
||||
name: Option<String>,
|
||||
},
|
||||
TabMove {
|
||||
workspace: WorkspaceId,
|
||||
tab: TabId,
|
||||
to: u64,
|
||||
},
|
||||
/// Record the tab's sidebar repo group, as resolved by the client.
|
||||
TabSetGroup {
|
||||
workspace: WorkspaceId,
|
||||
tab: TabId,
|
||||
group: Option<String>,
|
||||
},
|
||||
/// Split the leaf holding `pane`; `new` seeds the freshly-spawned second
|
||||
/// pane, `first` puts it on the upper/left side.
|
||||
PaneSplit {
|
||||
workspace: WorkspaceId,
|
||||
pane: u64,
|
||||
axis: Axis,
|
||||
ratio: f32,
|
||||
new: PaneSeed,
|
||||
first: bool,
|
||||
},
|
||||
/// Close one pane, collapsing its split (or the whole tab when it was the
|
||||
/// last pane). Answers [`ReplyOk::Panes`] like `TabClose`.
|
||||
PaneClose {
|
||||
workspace: WorkspaceId,
|
||||
pane: u64,
|
||||
},
|
||||
/// Move a split's divider. `path` addresses the split from the tab root,
|
||||
/// and a path the tree no longer has refuses rather than guessing.
|
||||
PaneSetRatio {
|
||||
workspace: WorkspaceId,
|
||||
tab: TabId,
|
||||
path: Vec<Side>,
|
||||
ratio: f32,
|
||||
},
|
||||
/// tmux's `move-pane`: take `pane` out of where it is and re-split it next
|
||||
/// to `to`, dissolving the source tab if that emptied it.
|
||||
PaneMove {
|
||||
workspace: WorkspaceId,
|
||||
pane: u64,
|
||||
to: u64,
|
||||
axis: Axis,
|
||||
first: bool,
|
||||
},
|
||||
/// The revival: rebind the leaf holding dead pane `old` to freshly-spawned
|
||||
/// successor `new`, spending the old registry record.
|
||||
PaneReplace {
|
||||
workspace: WorkspaceId,
|
||||
old: u64,
|
||||
new: PaneSeed,
|
||||
},
|
||||
}
|
||||
|
||||
impl ControlRequest {
|
||||
@@ -398,13 +542,29 @@ impl ControlRequest {
|
||||
// spawns `wsl.exe -l -q`, which is slow enough to deserve the same
|
||||
// budget as git.
|
||||
Shells => Duration::from_secs(20),
|
||||
WorkspaceList | WorkspaceGet { .. } | WorkspacePut { .. } | WorkspaceDelete { .. } => {
|
||||
Duration::from_secs(10)
|
||||
}
|
||||
// An attach is bookkeeping plus at most one push to a peer that may
|
||||
// be wedged — the push is `try`-shaped on the server, so this only
|
||||
// has to cover a slow link, not a slow client.
|
||||
WorkspaceAttach { .. } | WorkspaceDetach { .. } => Duration::from_secs(10),
|
||||
// Tree operations are a locked mutation plus one small file write,
|
||||
// so the budget covers a slow disk, not slow work.
|
||||
MachineGet
|
||||
| WorkspaceTree { .. }
|
||||
| WorkspaceCreate { .. }
|
||||
| WorkspaceRename { .. }
|
||||
| WorkspaceRemove { .. }
|
||||
| WorkspaceTouch { .. }
|
||||
| WorkspaceSetActiveTab { .. }
|
||||
| TabCreate { .. }
|
||||
| TabClose { .. }
|
||||
| TabRename { .. }
|
||||
| TabMove { .. }
|
||||
| TabSetGroup { .. }
|
||||
| PaneSplit { .. }
|
||||
| PaneClose { .. }
|
||||
| PaneSetRatio { .. }
|
||||
| PaneMove { .. }
|
||||
| PaneReplace { .. } => Duration::from_secs(10),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,7 +585,7 @@ impl ControlRequest {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A reply to one request: the operation's value, or why it couldn't run.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ControlReply {
|
||||
#[serde(rename = "ok")]
|
||||
Ok(ReplyOk),
|
||||
@@ -446,7 +606,7 @@ impl ControlReply {
|
||||
/// The successful half of a reply. One variant per result *shape*, not per
|
||||
/// request — several requests answer `Unit`, and `Stat` and `WriteFile` both
|
||||
/// answer `Meta`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReplyOk {
|
||||
Unit,
|
||||
@@ -465,8 +625,6 @@ pub enum ReplyOk {
|
||||
WatchId(u64),
|
||||
/// [`ControlRequest::Shells`]: what that machine can launch.
|
||||
Shells(ShellInventory),
|
||||
/// The workspace store's payload (M5).
|
||||
Json(serde_json::Value),
|
||||
/// [`ControlRequest::WorkspaceAttach`] succeeded. `took_over_from` names the
|
||||
/// machine whose session was displaced, so the client that *did* the taking
|
||||
/// can say so — only the notice going the other way is specified,
|
||||
@@ -474,6 +632,17 @@ pub enum ReplyOk {
|
||||
Attached {
|
||||
took_over_from: Option<String>,
|
||||
},
|
||||
/// [`ControlRequest::MachineGet`]: the machine's whole tree. Boxed for the
|
||||
/// same reason the tree replies below are: `ReplyOk` values live on the
|
||||
/// dispatch stack, and the common replies must not pay for the big ones.
|
||||
MachineTree(Box<Machine>),
|
||||
/// One workspace of the tree ([`ControlRequest::WorkspaceTree`] /
|
||||
/// [`ControlRequest::WorkspaceCreate`]).
|
||||
WorkspaceTree(Box<crate::core::machine::Workspace>),
|
||||
/// The tab an operation created ([`ControlRequest::TabCreate`]).
|
||||
TabTree(Box<Tab>),
|
||||
/// Pane ids an operation removed from the tree, for the caller to kill.
|
||||
Panes(Vec<u64>),
|
||||
}
|
||||
|
||||
/// An operation that could not be performed.
|
||||
@@ -580,7 +749,7 @@ impl WireErrorKind {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// An unsolicited server push, carried on [`kind::EVENT`] with `req_id == 0`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ControlEvent {
|
||||
/// Filesystem changes, coalesced and deduplicated by the server over a
|
||||
@@ -615,9 +784,35 @@ pub enum ControlEvent {
|
||||
workspace: String,
|
||||
by: String,
|
||||
},
|
||||
WorkspaceChanged {
|
||||
id: String,
|
||||
// `workspace_changed` was the retired record store's change notice; its
|
||||
// serde name is burned along with the record verbs.
|
||||
/// One incremental change to one tree workspace on this machine — the
|
||||
/// push half of the machine-tree verbs. The writer never receives its own
|
||||
/// operation back (origin exclusion, so an optimistically-applied edit is
|
||||
/// not applied twice); every other client applies the delta to its live
|
||||
/// window or, when it cannot, re-pulls the workspace with
|
||||
/// [`ControlRequest::WorkspaceTree`].
|
||||
///
|
||||
/// `workspace` is the [`WorkspaceId`] rendered as a string, matching how
|
||||
/// `Preempted` names its.
|
||||
Layout {
|
||||
workspace: String,
|
||||
delta: LayoutDelta,
|
||||
},
|
||||
/// The server dropped at least one [`Layout`](Self::Layout) push for this
|
||||
/// connection (its per-connection delta queue overflowed — see
|
||||
/// [`crate::host::server::LAYOUT_EVENT_QUEUE`]): the peer's mirrors are
|
||||
/// now wrong in a way no later delta repairs. So the client re-pulls the
|
||||
/// machine whole and resyncs its windows — the identical recovery a delta
|
||||
/// that will not apply already triggers, just server-announced instead of
|
||||
/// stumbled into. Connection-wide, because drops happen at the queue, not
|
||||
/// per workspace; the watch dialect's `WatchOverflow` is the precedent.
|
||||
///
|
||||
/// It arrives *instead of* the deltas the queue was still holding, not
|
||||
/// ahead of them: those are older than the gap and already inside the tree
|
||||
/// the client is about to pull, so delivering them after the pull would
|
||||
/// walk the client backwards through history it has already left behind.
|
||||
LayoutResync,
|
||||
}
|
||||
|
||||
/// Where control events that are nobody's *local* business end up.
|
||||
@@ -625,7 +820,7 @@ pub enum ControlEvent {
|
||||
/// [`RemoteHost`](crate::host::remote::RemoteHost) routes `Watch` and
|
||||
/// `WatchOverflow` into the subscription that asked for them, because those
|
||||
/// belong to a caller that is still holding a `WatchSub`. The rest —
|
||||
/// `Preempted`, `PaneExited`, `AgentStatus`, `WorkspaceChanged` — are about a
|
||||
/// `Preempted`, `PaneExited`, `AgentStatus`, `Layout` — are about a
|
||||
/// *window*, and the host layer has no window.
|
||||
///
|
||||
/// A process-wide observer rather than a parameter on `connect_with` because
|
||||
@@ -826,7 +1021,7 @@ fn require_nonzero(req_id: u64, what: &str) -> io::Result<()> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A control frame travelling client → server.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ControlClientMsg {
|
||||
Hello(ControlHello),
|
||||
Request {
|
||||
@@ -933,7 +1128,7 @@ impl ControlClientMsg {
|
||||
}
|
||||
|
||||
/// A control frame travelling server → client.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ControlServerMsg {
|
||||
HelloOk(ControlHelloOk),
|
||||
Response {
|
||||
@@ -1065,7 +1260,7 @@ pub const CLOSE_GRACE: Duration = Duration::from_millis(500);
|
||||
|
||||
/// A reply as the caller receives it: the value, plus the blob if the frame
|
||||
/// carried one.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ControlResponse {
|
||||
pub reply: ReplyOk,
|
||||
pub blob: Vec<u8>,
|
||||
@@ -1821,13 +2016,6 @@ mod tests {
|
||||
dirs: vec!["/home/me/proj".into(), "/home/me/proj/src".into()],
|
||||
},
|
||||
ControlRequest::WatchClose { id: 7 },
|
||||
ControlRequest::WorkspaceList,
|
||||
ControlRequest::WorkspaceGet { id: "w1".into() },
|
||||
ControlRequest::WorkspacePut {
|
||||
id: "w1".into(),
|
||||
json: serde_json::json!({ "tabs": [1, 2, 3] }),
|
||||
},
|
||||
ControlRequest::WorkspaceDelete { id: "w1".into() },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1874,7 +2062,6 @@ mod tests {
|
||||
stderr: vec![0x00, 0xff, 0xfe, b'\n'],
|
||||
})),
|
||||
ControlReply::Ok(ReplyOk::WatchId(42)),
|
||||
ControlReply::Ok(ReplyOk::Json(serde_json::json!({ "a": [1, null] }))),
|
||||
ControlReply::Err(WireError::new(WireErrorKind::NotFound, "no such file")),
|
||||
ControlReply::Err(WireError::new(
|
||||
WireErrorKind::PermissionDenied,
|
||||
@@ -1922,7 +2109,6 @@ mod tests {
|
||||
workspace: "w1".into(),
|
||||
by: "other-laptop".into(),
|
||||
},
|
||||
ControlEvent::WorkspaceChanged { id: "w1".into() },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2532,16 +2718,6 @@ mod tests {
|
||||
},
|
||||
s(20),
|
||||
),
|
||||
(R::WorkspaceList, s(10)),
|
||||
(R::WorkspaceGet { id: "w".into() }, s(10)),
|
||||
(
|
||||
R::WorkspacePut {
|
||||
id: "w".into(),
|
||||
json: serde_json::Value::Null,
|
||||
},
|
||||
s(10),
|
||||
),
|
||||
(R::WorkspaceDelete { id: "w".into() }, s(10)),
|
||||
];
|
||||
assert_eq!(
|
||||
cases.len(),
|
||||
@@ -2580,7 +2756,9 @@ mod tests {
|
||||
assert_eq!(ok.home, "/home/me");
|
||||
assert!(ok.has_feature(feature::CONTROL));
|
||||
assert!(ok.has_feature(feature::HOST_RPC));
|
||||
assert!(!ok.has_feature(feature::WORKSPACE_STORE));
|
||||
// The retired record store's bit is a burned name and must
|
||||
// never come back.
|
||||
assert!(!ok.has_feature("workspace-store"));
|
||||
}
|
||||
other => panic!("expected HelloOk, got {other:?}"),
|
||||
}
|
||||
|
||||
@@ -630,6 +630,12 @@ impl OutputGate {
|
||||
/// PTY master, writer, child) so a single `Mutex` guards everything the reader
|
||||
/// thread and the connection threads both touch.
|
||||
struct PaneState {
|
||||
/// The registry id of the pane this state belongs to — [`DaemonPane::id`],
|
||||
/// duplicated here so the code paths that only ever see the state (the
|
||||
/// signal appliers, [`DeathReporter::report`]) can name the pane when
|
||||
/// publishing an observation to the machine tree
|
||||
/// ([`crate::core::machine::observe_pane`]).
|
||||
id: u64,
|
||||
/// The replay ring: raw PTY bytes bounded to `RING_CAP`, segmented by the
|
||||
/// geometry they were recorded under so `attach` can replay each stretch
|
||||
/// at the width it was written for. Also the owner of the pane's current
|
||||
@@ -812,7 +818,14 @@ impl DeathReporter {
|
||||
}
|
||||
let mut st = state.lock().unwrap();
|
||||
st.alive = false;
|
||||
let pane = st.id;
|
||||
if shutting_down.load(Ordering::SeqCst) {
|
||||
drop(st);
|
||||
// Even a teardown the owner initiated is a death the tree must
|
||||
// hear about: the record's `live == false` *is* the client-visible
|
||||
// "awaiting revival" state, and it must not depend on which thread
|
||||
// noticed the child go.
|
||||
crate::core::machine::observe_pane(pane, |p| p.live = false);
|
||||
return;
|
||||
}
|
||||
let subscribed = st.subscriber.is_some();
|
||||
@@ -820,6 +833,7 @@ impl DeathReporter {
|
||||
let _ = sub.send(DaemonMsg::Exited { code: None });
|
||||
}
|
||||
drop(st);
|
||||
crate::core::machine::observe_pane(pane, |p| p.live = false);
|
||||
// A subscriber's later detach reclaims the pane, so only an *unattached*
|
||||
// death needs `on_dead` — and it fires at most once.
|
||||
if subscribed {
|
||||
@@ -870,6 +884,7 @@ impl DaemonPane {
|
||||
let writer = pair.master.take_writer()?;
|
||||
|
||||
let state = Arc::new(Mutex::new(PaneState {
|
||||
id,
|
||||
ring: ReplayRing::new(size),
|
||||
subscriber: None,
|
||||
subscriber_epoch: 0,
|
||||
@@ -979,6 +994,7 @@ impl DaemonPane {
|
||||
};
|
||||
|
||||
let state = Arc::new(Mutex::new(PaneState {
|
||||
id,
|
||||
ring: ReplayRing::new(size),
|
||||
subscriber: None,
|
||||
subscriber_epoch: 0,
|
||||
@@ -1240,7 +1256,24 @@ impl DaemonPane {
|
||||
let probed_cwd = poll_now.then(&foreground_cwd_fn).flatten();
|
||||
|
||||
let tr1 = trace.then(std::time::Instant::now);
|
||||
// Whether this chunk carries anything that *could*
|
||||
// move a fact the tree records. An ordinary output
|
||||
// chunk carries none of them, and must not pay for
|
||||
// two snapshots and a compare per read: a build's
|
||||
// worth of stdout is thousands of chunks and no
|
||||
// facts at all.
|
||||
let may_change_facts = signals.cwd.is_some()
|
||||
|| !signals.agent_events.is_empty()
|
||||
|| signals.notification.is_some()
|
||||
// A prompt boundary: on Windows the agent
|
||||
// identity rides the `133;C` capture, and
|
||||
// everywhere the OSC 7 cwd travels with it.
|
||||
|| !signals.shell.is_empty()
|
||||
|| remote.is_some()
|
||||
|| agent.is_some()
|
||||
|| probed_cwd.is_some();
|
||||
let mut st = state.lock().unwrap();
|
||||
let facts_before = may_change_facts.then(|| observed_facts(&st));
|
||||
st.ring.append(bytes);
|
||||
if let Some(sub) = &st.subscriber {
|
||||
// A send error just means the client is gone; ignore
|
||||
@@ -1265,6 +1298,47 @@ impl DaemonPane {
|
||||
if let Some(tr1) = tr1 {
|
||||
tr_disp_t += tr1.elapsed();
|
||||
}
|
||||
// Publish what this chunk changed to the machine
|
||||
// tree — outside the state lock, because the store
|
||||
// broadcasts to every client of this machine and
|
||||
// this thread's stalls are the child's write
|
||||
// stalls. Gated twice over: `may_change_facts`
|
||||
// keeps plain output free, and the compare below
|
||||
// keeps a re-reported cwd from becoming a store
|
||||
// mutation.
|
||||
let pane = st.id;
|
||||
// Read *with* the facts, not assumed: on Windows
|
||||
// the exit monitor can report the death (flipping
|
||||
// `alive`) while this thread is still draining
|
||||
// ConPTY's buffered output, and the death report
|
||||
// is latched — a "proof of life" published here
|
||||
// after it would mark a dead pane live forever.
|
||||
let alive = st.alive;
|
||||
let facts_after = may_change_facts.then(|| observed_facts(&st));
|
||||
drop(st);
|
||||
if let (Some(before), Some(after)) = (facts_before, facts_after)
|
||||
&& facts_changed(&before, &after)
|
||||
{
|
||||
let (cwd, agent) = after;
|
||||
crate::core::machine::observe_pane(pane, |p| {
|
||||
// An unknown cwd never clears a seeded one:
|
||||
// the spawn directory in the record is
|
||||
// better revival information than nothing.
|
||||
if cwd.is_some() {
|
||||
p.cwd = cwd;
|
||||
}
|
||||
// The agent fact applies wholesale — its
|
||||
// `None` means the agent left the
|
||||
// foreground, and a revival must not
|
||||
// resume a session that already ended.
|
||||
p.agent = agent;
|
||||
// Output is proof of life — but only while
|
||||
// the pane still is; see `alive` above.
|
||||
if alive {
|
||||
p.live = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(_) => break, // EIO after hangup, etc.
|
||||
@@ -1882,6 +1956,62 @@ fn attach_subscriber(st: &mut PaneState, subscriber: Sender<DaemonMsg>) -> u64 {
|
||||
st.subscriber_epoch
|
||||
}
|
||||
|
||||
/// The slice of a pane's state the machine tree records about it — the cwd a
|
||||
/// successor would spawn in, and the agent facts a successor would resume.
|
||||
/// Captured before and after a chunk's signal application so the (rare) change
|
||||
/// is published outside the state lock; see the reader loop.
|
||||
///
|
||||
/// The cwd crosses as a `String` because the tree's records do (the dialect's
|
||||
/// path rule); the loss, if any, happens here where it can be seen next to the
|
||||
/// path that caused it.
|
||||
fn observed_facts(st: &PaneState) -> (Option<String>, Option<crate::core::machine::AgentFacts>) {
|
||||
let cwd = st.cwd.as_ref().map(|p| p.to_string_lossy().into_owned());
|
||||
let agent = st.agent.map(|agent| crate::core::machine::AgentFacts {
|
||||
agent,
|
||||
session_id: st.agent_session.as_ref().and_then(|s| s.session_id.clone()),
|
||||
// The session's own argv record wins — it survives the chip clearing —
|
||||
// with the identity poll's capture as the fallback until it is stamped.
|
||||
launch_argv: st
|
||||
.agent_session
|
||||
.as_ref()
|
||||
.and_then(|s| s.launch_argv.clone())
|
||||
.or_else(|| st.agent_argv.clone()),
|
||||
status: st.agent_session.as_ref().map(|s| s.status),
|
||||
});
|
||||
(cwd, agent)
|
||||
}
|
||||
|
||||
/// Whether a chunk's facts are worth a store mutation.
|
||||
///
|
||||
/// The coarse agent status is deliberately **outside** the gate: it flips on
|
||||
/// every hook event (working ↔ waiting ↔ idle), each of which would otherwise
|
||||
/// rewrite `machine.json` from the PTY reader thread, and it is documented
|
||||
/// display-only. It still *rides along* — whenever a load-bearing fact
|
||||
/// changes, the record published carries the current status too.
|
||||
fn facts_changed(
|
||||
before: &(Option<String>, Option<crate::core::machine::AgentFacts>),
|
||||
after: &(Option<String>, Option<crate::core::machine::AgentFacts>),
|
||||
) -> bool {
|
||||
before.0 != after.0 || agent_facts_changed(before.1.as_ref(), after.1.as_ref())
|
||||
}
|
||||
|
||||
/// [`facts_changed`]'s agent half: equality over every field but the status.
|
||||
/// Compared field by field rather than by cloning-and-blanking, because this
|
||||
/// runs on the pane's reader thread and the argv it would clone is a `Vec` of
|
||||
/// `String`s.
|
||||
fn agent_facts_changed(
|
||||
before: Option<&crate::core::machine::AgentFacts>,
|
||||
after: Option<&crate::core::machine::AgentFacts>,
|
||||
) -> bool {
|
||||
match (before, after) {
|
||||
(None, None) => false,
|
||||
(Some(a), Some(b)) => {
|
||||
a.agent != b.agent || a.session_id != b.session_id || a.launch_argv != b.launch_argv
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply sniffed signals to the shared state and notify the subscriber of any cwd
|
||||
/// / prompt change. Called with the state lock held.
|
||||
fn apply_signals(st: &mut PaneState, signals: SniffSignals) {
|
||||
@@ -3649,6 +3779,9 @@ mod tests {
|
||||
/// A fresh `PaneState` for the PTY-less state-machine tests.
|
||||
fn test_state(alive: bool) -> PaneState {
|
||||
PaneState {
|
||||
// Unit tests publish observations nowhere (no store is installed
|
||||
// in this process), so the id is never consulted.
|
||||
id: 0,
|
||||
ring: ReplayRing::new(ws(80, 24)),
|
||||
subscriber: None,
|
||||
subscriber_epoch: 0,
|
||||
@@ -3662,6 +3795,49 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the machine tree is told about a pane is exactly what a successor
|
||||
/// needs: the cwd as a string, the session's own argv over the poll's
|
||||
/// capture (the session record survives chip churn), and the coarse
|
||||
/// status. No agent, no facts — a revival must not resume a session that
|
||||
/// was never there.
|
||||
#[test]
|
||||
fn observed_facts_prefer_the_sessions_argv_and_carry_its_status() {
|
||||
use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent};
|
||||
|
||||
let mut st = test_state(true);
|
||||
assert_eq!(observed_facts(&st), (None, None));
|
||||
|
||||
st.cwd = Some(PathBuf::from("/work/api"));
|
||||
st.agent = Some(CLIAgent::Claude);
|
||||
st.agent_argv = Some(vec!["claude".into()]);
|
||||
st.agent_session = Some(AgentSessionState {
|
||||
status: AgentStatus::Working,
|
||||
session_id: Some("sess-1".into()),
|
||||
launch_argv: Some(vec!["claude".into(), "--model".into(), "opus".into()]),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
let (cwd, agent) = observed_facts(&st);
|
||||
assert_eq!(cwd.as_deref(), Some("/work/api"));
|
||||
let agent = agent.expect("an agent in the foreground is a fact");
|
||||
assert_eq!(agent.agent, CLIAgent::Claude);
|
||||
assert_eq!(agent.session_id.as_deref(), Some("sess-1"));
|
||||
assert_eq!(
|
||||
agent.launch_argv.as_deref(),
|
||||
Some(&["claude".to_string(), "--model".into(), "opus".into()][..]),
|
||||
"the session's own argv outranks the identity poll's capture"
|
||||
);
|
||||
assert_eq!(agent.status, Some(AgentStatus::Working));
|
||||
|
||||
// The poll's capture is the fallback until the session stamps its own.
|
||||
st.agent_session = None;
|
||||
let (_, agent) = observed_facts(&st);
|
||||
assert_eq!(
|
||||
agent.unwrap().launch_argv.as_deref(),
|
||||
Some(&["claude".to_string()][..])
|
||||
);
|
||||
}
|
||||
|
||||
/// The full daemon-side rich-status path: sentinel OSC events sniffed out
|
||||
/// of the byte stream drive the pane's session state machine, identify the
|
||||
/// agent when argv detection hasn't, and stream every change to the
|
||||
|
||||
@@ -51,6 +51,16 @@ pub const MAX_FRAME: usize = 64 * 1024 * 1024;
|
||||
///
|
||||
/// ## History
|
||||
///
|
||||
/// - **v4** — the daemon serves the machine tree. `tty7 --daemon` now runs
|
||||
/// the shared `run_daemon`: a control listener (carrying the daemon-owned
|
||||
/// workspace tree) beside the pane listener. No pane frame changed, so by
|
||||
/// the letter of the rule above this is additive — but the *service* is
|
||||
/// not: a v3 daemon has no control socket at all, and a GUI from this
|
||||
/// build that silently adopted one would connect its control link into the
|
||||
/// void forever — every window hydrating from a tree that never answers,
|
||||
/// which renders as empty windows with no error anywhere. The bump routes
|
||||
/// that meeting into `ensure_running`'s existing keep-or-restart prompt,
|
||||
/// where "restart the background service" is the fix.
|
||||
/// - **v3** — the [`control`](super::control) dialect (kinds 60-63) and
|
||||
/// [`DaemonVersion::features`]. By the rule above this is *additive* and
|
||||
/// would not earn a bump on its own: a v2 daemon meeting a control frame
|
||||
@@ -66,7 +76,7 @@ pub const MAX_FRAME: usize = 64 * 1024 * 1024;
|
||||
/// downgrade (a v2 GUI spawns the pane, a v1 GUI later attaches to it), but
|
||||
/// loses it silently. The handshake now catches that skew and asks.
|
||||
/// - **v1** — the dialect at the time versioning landed.
|
||||
pub const PROTOCOL_VERSION: u32 = 3;
|
||||
pub const PROTOCOL_VERSION: u32 = 4;
|
||||
|
||||
/// Capability string for [`DaemonVersion::features`]: this daemon records
|
||||
/// which workspace each pane was spawned for and reports it in `List`'s
|
||||
@@ -93,13 +103,17 @@ pub struct DaemonVersion {
|
||||
/// every user whose daemon happens to predate it.
|
||||
#[serde(default)]
|
||||
pub features: Vec<String>,
|
||||
/// Identity of this daemon *process*, minted once at startup. Pane ids are
|
||||
/// only meaningful within one daemon process — after a restart the numbers
|
||||
/// start over from 1 and land on unrelated shells — so a client that
|
||||
/// persists pane ids records this next to them and treats a mismatch as
|
||||
/// "every saved id is stale" (see `Workspace::daemon_instance`). Empty for
|
||||
/// daemons that predate the field; the remote `tty7-server` announces the
|
||||
/// same identity through its control hello.
|
||||
/// Identity of this daemon *process*, minted once at startup — the same
|
||||
/// identity the control hello announces
|
||||
/// ([`ControlHelloOk::instance`](crate::daemon::control::ControlHelloOk::instance)),
|
||||
/// which is what reconnect logic actually consults to tell "the link
|
||||
/// blinked" from "a different process answers now". PTYs die with the
|
||||
/// process, so a changed instance means every previously live pane is
|
||||
/// gone; the machine tree records the same fact per pane (`load_machine`
|
||||
/// clears every `live` flag on open), and a daemon carrying a tree seeds
|
||||
/// its pane ids *past* everything the tree names rather than restarting
|
||||
/// from 1, so a stale id can never alias a new shell. Empty for daemons
|
||||
/// that predate the field — "unknown", never "restarted".
|
||||
#[serde(default)]
|
||||
pub instance: String,
|
||||
}
|
||||
@@ -113,10 +127,11 @@ impl DaemonVersion {
|
||||
DaemonVersion {
|
||||
protocol: PROTOCOL_VERSION,
|
||||
build: env!("CARGO_PKG_VERSION").to_string(),
|
||||
// The local session daemon speaks the pane protocol only. The
|
||||
// control dialect is served by `tty7-server`, which advertises
|
||||
// `control` / `host-rpc` itself; claiming them here would make the
|
||||
// GUI open a control connection this process cannot answer.
|
||||
// This reply describes the *pane* socket only. The control
|
||||
// dialect lives on the daemon's separate control socket, whose
|
||||
// own `ControlHelloOk` announces `control` / `host-rpc` /
|
||||
// `machine-tree` for itself; claiming them here would say the
|
||||
// pane socket speaks frames it does not.
|
||||
//
|
||||
// `pane-owner` *is* a pane-protocol capability, so every process
|
||||
// serving panes from this build advertises it.
|
||||
@@ -2630,7 +2645,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_local_daemon_does_not_claim_the_control_dialect() {
|
||||
let v = DaemonVersion::current();
|
||||
assert_eq!(v.protocol, 3);
|
||||
assert_eq!(v.protocol, 4);
|
||||
assert!(
|
||||
!v.has_feature(crate::daemon::control::feature::CONTROL),
|
||||
"the session daemon must not advertise a dialect it cannot serve"
|
||||
|
||||
@@ -142,7 +142,7 @@ const REPLY_TIMEOUT: Duration = Duration::from_secs(240);
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RouteChannel {
|
||||
/// Host RPC, the workspace store, event pushes — `daemon::control`.
|
||||
/// Host RPC, the machine tree, event pushes — `daemon::control`.
|
||||
#[default]
|
||||
Control,
|
||||
/// One pane: `Spawn`/`Attach`/`Input`/`Output` — `daemon::protocol`.
|
||||
|
||||
@@ -46,6 +46,36 @@ impl Registry {
|
||||
self.next_id.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Never mint an id `machine`'s tree already names — see the caller in
|
||||
/// [`run`] for the aliasing failures this closes. The registry and the
|
||||
/// leaves are checked both: a pane record can outlive its leaf briefly,
|
||||
/// and either one aliased is one too many.
|
||||
fn seed_ids_past(&self, machine: &crate::core::machine::Machine) {
|
||||
let max = machine
|
||||
.panes
|
||||
.iter()
|
||||
.map(|p| p.id)
|
||||
.chain(
|
||||
machine
|
||||
.workspaces
|
||||
.iter()
|
||||
.flat_map(|w| w.tabs.iter())
|
||||
.flat_map(|t| t.root.pane_ids()),
|
||||
)
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
// Saturating: a tree (or a hostile seed) naming u64::MAX must not
|
||||
// panic the daemon at startup. The counter parking at the ceiling is
|
||||
// a bounded absurdity; overflowing is a dead process.
|
||||
let next = max.saturating_add(1);
|
||||
// fetch_max rather than store: harmless today (this runs before any
|
||||
// spawn), but a seed must never move the counter backwards.
|
||||
let before = self.next_id.fetch_max(next, Ordering::Relaxed);
|
||||
if next > before {
|
||||
log::info!("pane ids start at {next} (the tree names panes up to {max})");
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(&self, pane: Arc<DaemonPane>) {
|
||||
self.panes.lock().unwrap().insert(pane.id, pane);
|
||||
}
|
||||
@@ -87,6 +117,59 @@ impl Registry {
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the orphan sweep looks, which doubles as its grace period: a pane
|
||||
/// is only reported after it has been unreferenced across two consecutive
|
||||
/// looks, so a freshly-spawned pane whose adopting operation is still in
|
||||
/// flight is never flagged.
|
||||
const ORPHAN_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(600);
|
||||
|
||||
/// Periodically report live panes the machine tree does not reference.
|
||||
///
|
||||
/// **Log-only, on purpose.** An unreferenced pane is not proof of a leak:
|
||||
/// a native-SSH pane opened inside a *remote* workspace's window runs in this
|
||||
/// (the client's) daemon while belonging to the other machine's tree, so it is
|
||||
/// unreferenced here by design — and a reclaim would kill a session the user
|
||||
/// is looking at. Until the tree provably references everything legitimate,
|
||||
/// the sweep's job is to make leaks observable, not to act on them; killing
|
||||
/// can be layered on once the log has shown the false-positive rate is zero.
|
||||
fn spawn_orphan_sweep(registry: Arc<Registry>) {
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("tty7-orphan-sweep".into())
|
||||
.spawn(move || {
|
||||
let mut previous: std::collections::HashSet<u64> = std::collections::HashSet::new();
|
||||
loop {
|
||||
std::thread::sleep(ORPHAN_SWEEP_INTERVAL);
|
||||
// No tree served (a pane-only daemon) means no opinion.
|
||||
let Some(store) = crate::core::machine::observed_store() else {
|
||||
continue;
|
||||
};
|
||||
let machine = store.machine();
|
||||
let referenced: std::collections::HashSet<u64> = machine
|
||||
.workspaces
|
||||
.iter()
|
||||
.flat_map(|w| w.tabs.iter())
|
||||
.flat_map(|t| t.root.pane_ids())
|
||||
.collect();
|
||||
let orphans: std::collections::HashSet<u64> = registry
|
||||
.list()
|
||||
.into_iter()
|
||||
.filter(|p| p.alive && !referenced.contains(&p.pane_id))
|
||||
.map(|p| p.pane_id)
|
||||
.collect();
|
||||
for id in orphans.intersection(&previous) {
|
||||
log::info!(
|
||||
"pane {id} is running but no workspace tree references it \
|
||||
(kept; the sweep only reports — see spawn_orphan_sweep)"
|
||||
);
|
||||
}
|
||||
previous = orphans;
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("could not start the orphan-pane sweep: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a pane id to its live native-SSH connection, for the SFTP control
|
||||
/// handlers. Errors (as a client-facing string) when the pane is unknown or isn't
|
||||
/// a native-SSH pane with an established connection (a PTY / compat-`ssh` pane, or
|
||||
@@ -103,6 +186,82 @@ fn ssh_connection_for(
|
||||
})
|
||||
}
|
||||
|
||||
/// Run the *whole* daemon — panes **and** control — until killed. The one
|
||||
/// entry point behind both `tty7 --daemon` and `tty7-server --daemon`.
|
||||
///
|
||||
/// Local and remote are deliberately the same shape: a machine is a machine,
|
||||
/// whether the client sits on it or an ocean away, and the design's terminal
|
||||
/// state is "one machine = one daemon = one workspace tree". That tree is
|
||||
/// served over the control dialect, so the *local* daemon has to speak it too —
|
||||
/// which is why this lives here rather than staying a `tty7-server` detail.
|
||||
///
|
||||
/// Control comes up first, and on its own thread: a machine that cannot host
|
||||
/// panes (no pty, a locked-down container) should still be able to back a
|
||||
/// workspace's files, so a control failure is logged and stepped over rather
|
||||
/// than being fatal. The pane listener then owns this thread until the process
|
||||
/// is killed, exactly as [`run`] always has.
|
||||
///
|
||||
/// Both platforms serve it, over the transport each one's pane socket already
|
||||
/// uses: a Unix-domain socket gated by its file permissions, or a loopback
|
||||
/// `TcpListener` gated by the token in a user-private marker file. The tree is
|
||||
/// what a client's layout *is* now, so a platform without a control listener is
|
||||
/// a platform where tabs do not come back — which is not a difference a build
|
||||
/// gets to have.
|
||||
pub fn run_daemon() -> anyhow::Result<()> {
|
||||
// Reported on **stderr**, not only the log: a headless server's log file is
|
||||
// off unless `TTY7_LOG` asks for it, and the bound path is this daemon's
|
||||
// one observable answer to "where do I connect". The remote-router test
|
||||
// reads this exact line back to prove the client's derivation and the
|
||||
// server's bind agree, so the prefix is part of the contract.
|
||||
#[cfg(any(unix, windows))]
|
||||
match crate::host::server::spawn_control_listener_with(
|
||||
crate::host::local::LocalHost::shared(),
|
||||
control_services(),
|
||||
) {
|
||||
Ok(path) => eprintln!("tty7-server: control socket at {}", path.display()),
|
||||
Err(e) => eprintln!("tty7-server: control listener unavailable: {e}"),
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
log::info!("no control listener on this platform; serving panes only");
|
||||
|
||||
run()
|
||||
}
|
||||
|
||||
/// What this machine offers over a control connection, beyond its filesystem.
|
||||
///
|
||||
/// The machine tree is why a daemon serves control at all: the workspace
|
||||
/// list, the tab/pane tree and each pane's facts live on **the machine the
|
||||
/// panes run on**, so that every client of this machine — the GUI on it, a
|
||||
/// laptop across the world — sees the same thing. Clients keep only their own
|
||||
/// view state.
|
||||
///
|
||||
/// A machine with no home directory to place the file in still serves files
|
||||
/// and panes — it simply omits `machine-tree` from its capabilities, and
|
||||
/// clients see the same "does not serve the machine tree" answer a server
|
||||
/// without one has always given.
|
||||
pub fn control_services() -> crate::host::server::Services {
|
||||
use crate::core::machine::MachineStore;
|
||||
// Reported on stderr as well as the log, like the socket line in
|
||||
// [`run_daemon`]: on a headless box the log file is off by default, and
|
||||
// "does this daemon actually serve the tree" is the first question a
|
||||
// capability mismatch raises.
|
||||
match MachineStore::shared() {
|
||||
Ok(machine) => {
|
||||
eprintln!("machine tree at {}", machine.path().display());
|
||||
// From here on the pane server's own observations — OSC 7 cwds,
|
||||
// agent identities, deaths — land on the tree's pane records, so
|
||||
// what a client revives from is what the machine saw, not what
|
||||
// some client last remembered to write.
|
||||
crate::core::machine::publish_observations(&machine);
|
||||
crate::host::server::Services::with_machine(machine)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("no machine tree ({e}); serving files and panes only");
|
||||
crate::host::server::Services::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the daemon: bind the socket and serve connections forever. Returns `Err`
|
||||
/// only on a fatal setup failure (bad socket path, bind error); the accept loop
|
||||
/// itself runs until the process is killed.
|
||||
@@ -144,6 +303,29 @@ pub fn run() -> anyhow::Result<()> {
|
||||
#[cfg(unix)]
|
||||
serve_sigterm(registry.clone());
|
||||
|
||||
// Pane ids must never alias across restarts: the persisted tree still
|
||||
// names the previous process's panes, and a fresh process minting from 1
|
||||
// would hand a new shell an id some dead leaf claims — at which point the
|
||||
// record's `live` flag flips back on for the wrong pane, revival stalls on
|
||||
// "pane N is already part of this machine's tree", and a window attaching
|
||||
// by the stale id steals an unrelated workspace's stream. Starting past
|
||||
// everything the tree knows makes the id a name, not a slot.
|
||||
if let Some(store) = crate::core::machine::observed_store() {
|
||||
registry.seed_ids_past(&store.machine());
|
||||
// And let the store ask *us* whether a seeded pane is still alive at
|
||||
// registration time — the pane that dies between its spawn and its
|
||||
// adopting operation would otherwise be filed `live: true` with its
|
||||
// death observation already dropped, and nothing left to flip it.
|
||||
let probe = registry.clone();
|
||||
store.set_liveness_probe(Arc::new(move |id| {
|
||||
probe.get(id).is_some_and(|pane| pane.info().alive)
|
||||
}));
|
||||
}
|
||||
|
||||
// Now that the tree has an owner filling it, the daemon can *see* panes
|
||||
// nothing references any more — but it only reports them, deliberately.
|
||||
spawn_orphan_sweep(registry.clone());
|
||||
|
||||
for stream in listener.incoming() {
|
||||
match stream {
|
||||
Ok(stream) => {
|
||||
@@ -209,14 +391,31 @@ fn serve_sigterm(registry: Arc<Registry>) {
|
||||
if unsafe { libc::sigwait(&set, &mut sig) } == 0 {
|
||||
log::info!("daemon shutting down on SIGTERM");
|
||||
registry.drain_and_kill();
|
||||
transport::remove_stale_endpoint();
|
||||
crate::daemon::pidfile::remove();
|
||||
on_shutdown();
|
||||
std::process::exit(0);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// What every daemon exit owes the next one.
|
||||
///
|
||||
/// The tree's observations first: a pane's cwd and its agent session are
|
||||
/// deferred by design (`machine::Persist::Soon`) and are exactly what the next
|
||||
/// launch revives that pane from, so the last couple of seconds of them are
|
||||
/// worth one write on the way out. Then the endpoint markers — **both**
|
||||
/// dialects', since on Windows each listener has its own — and the pidfile, so
|
||||
/// nothing left on disk points at a process that is gone.
|
||||
fn on_shutdown() {
|
||||
if let Some(store) = crate::core::machine::observed_store() {
|
||||
store.flush();
|
||||
}
|
||||
transport::remove_stale_endpoint();
|
||||
#[cfg(windows)]
|
||||
crate::host::server::remove_control_endpoint();
|
||||
crate::daemon::pidfile::remove();
|
||||
}
|
||||
|
||||
/// Handle one connection start-to-finish. Reads the opening `ClientMsg` and
|
||||
/// dispatches; for the streaming variants it then runs [`stream_pane`].
|
||||
fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
@@ -366,8 +565,7 @@ fn handle_conn(stream: Stream, registry: Arc<Registry>) -> anyhow::Result<()> {
|
||||
// place the daemon terminates itself.
|
||||
log::info!("daemon shutting down on client request");
|
||||
registry.drain_and_kill();
|
||||
transport::remove_stale_endpoint();
|
||||
crate::daemon::pidfile::remove();
|
||||
on_shutdown();
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
@@ -774,6 +972,45 @@ mod tests {
|
||||
assert_eq!(reg.alloc_id(), 3);
|
||||
}
|
||||
|
||||
/// Pane ids are names, not slots: a fresh process must never re-mint an id
|
||||
/// the persisted tree still references, or a stale leaf aliases a new
|
||||
/// shell — the tree marks the wrong pane live, revival's re-registration
|
||||
/// is refused forever, and an attach by the old id steals another
|
||||
/// workspace's stream.
|
||||
#[test]
|
||||
fn pane_ids_never_alias_what_the_persisted_tree_references() {
|
||||
use crate::core::machine::{MachineStore, PaneSeed};
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let store = MachineStore::open(dir.path().join("machine.json"));
|
||||
let ws = store.workspace_create(None, None, None).unwrap();
|
||||
store
|
||||
.tab_create(ws.id, None, PaneSeed::bare(7), None, None)
|
||||
.unwrap();
|
||||
|
||||
let reg = Registry::new();
|
||||
reg.seed_ids_past(&store.machine());
|
||||
assert_eq!(reg.alloc_id(), 8, "past the highest id the tree names");
|
||||
|
||||
// A seed can only move the counter forward.
|
||||
reg.seed_ids_past(&store.machine());
|
||||
assert_eq!(reg.alloc_id(), 9);
|
||||
}
|
||||
|
||||
/// A tree naming `u64::MAX` (a corrupted file, an absurd client seed)
|
||||
/// must not panic the daemon at startup: `max + 1` overflowed in a debug
|
||||
/// build, taking every pane on the machine down with a bookkeeping add.
|
||||
#[test]
|
||||
fn a_tree_naming_the_maximum_pane_id_does_not_panic_the_seed() {
|
||||
use crate::core::machine::{Machine, PaneRecord};
|
||||
let reg = Registry::new();
|
||||
reg.seed_ids_past(&Machine {
|
||||
workspaces: Vec::new(),
|
||||
panes: vec![PaneRecord::new(u64::MAX)],
|
||||
});
|
||||
// The counter parks at the ceiling — a bounded absurdity, not a crash.
|
||||
assert_eq!(reg.alloc_id(), u64::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_registry_get_remove_list_are_empty() {
|
||||
let reg = Registry::new();
|
||||
@@ -1006,11 +1243,9 @@ mod tests {
|
||||
let (client, server) = UnixStream::pair().unwrap();
|
||||
let writer = spawn_writer(rx, server, Arc::new(crate::daemon::pane::OutputGate::new()));
|
||||
|
||||
// Kill the client end first, then hand the writer a message: the
|
||||
// Kill the client end first, then hand the writer messages: an
|
||||
// encode hits a broken pipe and the thread must bail on its own.
|
||||
drop(client);
|
||||
tx.send(DaemonMsg::Output(b"into the void".to_vec()))
|
||||
.unwrap();
|
||||
|
||||
// Bounded poll rather than a bare `join()`: the sender stays alive
|
||||
// for the whole wait, so only the write-failure path can finish the
|
||||
@@ -1020,8 +1255,16 @@ mod tests {
|
||||
// running the whole suite in parallel can leave this thread
|
||||
// unscheduled for seconds. A tight bound turns that into a flake
|
||||
// that says nothing about the behaviour under test.
|
||||
//
|
||||
// Kept fed rather than sent one message: the first write into a
|
||||
// freshly-closed socket can *succeed* (the kernel has not
|
||||
// processed the peer's close yet, especially under load), and a
|
||||
// writer that swallowed it would park in `recv()` for the rest of
|
||||
// the deadline. Only a later write is guaranteed to see the
|
||||
// broken pipe, so the loop keeps offering them.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while !writer.is_finished() && std::time::Instant::now() < deadline {
|
||||
let _ = tx.send(DaemonMsg::Output(b"into the void".to_vec()));
|
||||
thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(
|
||||
|
||||
@@ -73,17 +73,6 @@ pub fn take_mismatched_daemon() -> Option<MismatchedDaemon> {
|
||||
/// identity of a daemon that is no longer the one answering.
|
||||
static LOCAL_DAEMON: std::sync::Mutex<Option<DaemonVersion>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// The serving daemon's process identity, when it reports one. `None` means
|
||||
/// "unknown" (an older daemon, or nothing running) — callers must treat that
|
||||
/// as "no instance check possible", never as a mismatch.
|
||||
pub fn local_daemon_instance() -> Option<String> {
|
||||
let guard = LOCAL_DAEMON.lock().ok()?;
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|v| v.instance.clone())
|
||||
.filter(|i| !i.is_empty())
|
||||
}
|
||||
|
||||
/// Whether the serving daemon advertises `feature`
|
||||
/// (e.g. [`crate::daemon::protocol::FEATURE_PANE_OWNER`]). `false` when
|
||||
/// nothing is known — the safe answer, because every capability gated on this
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
//! `authenticate` rejects any connection that doesn't match — so only a process
|
||||
//! that could read the user-private file gets in. See [`imp_windows`].
|
||||
//!
|
||||
//! One daemon serves two dialects on two listeners — panes and control — which
|
||||
//! on Unix are two socket files and here are two port files, each with its own
|
||||
//! ephemeral port and its own token (`bind_endpoint`). The control listener's is
|
||||
//! `control.port`; [`crate::host::server`] owns it, since that is where the
|
||||
//! dialect lives.
|
||||
//!
|
||||
//! All endpoint state lives under the (config-dir-aware) config directory, so
|
||||
//! `--config-dir` / `cargo dev` isolation reaches the daemon on every platform.
|
||||
|
||||
@@ -376,8 +382,16 @@ mod imp_windows {
|
||||
/// Length of the per-daemon auth token, in bytes. 256 bits from the OS CSPRNG:
|
||||
/// unguessable without reading the (user-private) port file, so possessing it
|
||||
/// proves the connecting process runs as the same user.
|
||||
const TOKEN_LEN: usize = 32;
|
||||
type Token = [u8; TOKEN_LEN];
|
||||
pub const TOKEN_LEN: usize = 32;
|
||||
pub type Token = [u8; TOKEN_LEN];
|
||||
|
||||
/// The pane dialect's endpoint marker.
|
||||
///
|
||||
/// Named, because one daemon serves two dialects on two listeners — the
|
||||
/// same shape it has on Unix, where they are two socket files — and each
|
||||
/// records its own port and mints its own token. See
|
||||
/// [`bind_endpoint`].
|
||||
const PANE_PORT_FILE: &str = "daemon.port";
|
||||
|
||||
/// This daemon's auth token, minted once at [`bind`] and checked by
|
||||
/// [`authenticate`] on every accepted connection. A process global because the
|
||||
@@ -446,12 +460,21 @@ mod imp_windows {
|
||||
/// "endpoint exists" marker, and — being under the user-private config dir —
|
||||
/// its contents (the token) are readable only by the same user.
|
||||
fn port_path() -> Option<PathBuf> {
|
||||
config::config_path("daemon.port")
|
||||
port_path_named(PANE_PORT_FILE)
|
||||
}
|
||||
|
||||
/// [`port_path`] for any of this daemon's endpoints.
|
||||
pub fn port_path_named(file: &str) -> Option<PathBuf> {
|
||||
config::config_path(file)
|
||||
}
|
||||
|
||||
/// Read the recorded loopback port + token, if the port file exists and parses.
|
||||
fn read_port_file() -> Option<(u16, Token)> {
|
||||
let path = port_path()?;
|
||||
read_port_file_named(PANE_PORT_FILE)
|
||||
}
|
||||
|
||||
fn read_port_file_named(file: &str) -> Option<(u16, Token)> {
|
||||
let path = port_path_named(file)?;
|
||||
let contents = std::fs::read_to_string(path).ok()?;
|
||||
parse_port_file(&contents)
|
||||
}
|
||||
@@ -491,6 +514,13 @@ mod imp_windows {
|
||||
authenticate_with(stream, expected)
|
||||
}
|
||||
|
||||
/// [`authenticate`] for a connection on one of this daemon's *other*
|
||||
/// endpoints, whose token its listener holds rather than reading from the
|
||||
/// process global.
|
||||
pub fn check_endpoint_token(stream: &mut Stream, expected: &Token) -> io::Result<()> {
|
||||
authenticate_with(stream, expected)
|
||||
}
|
||||
|
||||
/// Pure core of [`authenticate`]: read a token off `reader` and compare it to
|
||||
/// `expected`. Split out so the handshake is testable without a live daemon or
|
||||
/// the process-global token.
|
||||
@@ -531,8 +561,27 @@ mod imp_windows {
|
||||
/// this daemon's freshly-minted auth token — in the port file so the GUI can
|
||||
/// find *and* authenticate to it. Ensures the config dir exists first.
|
||||
pub fn bind() -> anyhow::Result<Listener> {
|
||||
let path = port_path()
|
||||
.ok_or_else(|| anyhow::anyhow!("could not resolve daemon port path (no config dir)"))?;
|
||||
// Mint the pane dialect's token once for this daemon's lifetime;
|
||||
// `authenticate` checks against the same value.
|
||||
let token = *DAEMON_TOKEN.get_or_init(make_token);
|
||||
let (listener, _) = bind_named(PANE_PORT_FILE, token)?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
/// [`bind`] for a second dialect in this same daemon: its own ephemeral
|
||||
/// port, its own token, its own marker file beside `daemon.port`.
|
||||
///
|
||||
/// Answers the token as well as the listener, because a second endpoint has
|
||||
/// nowhere process-global to keep it — its accept loop holds it and checks
|
||||
/// each connection with [`check_endpoint_token`]. One token per endpoint, so
|
||||
/// a client that learned one cannot present it to the other.
|
||||
pub fn bind_endpoint(file: &str) -> anyhow::Result<(Listener, Token)> {
|
||||
bind_named(file, make_token())
|
||||
}
|
||||
|
||||
fn bind_named(file: &str, token: Token) -> anyhow::Result<(Listener, Token)> {
|
||||
let path = port_path_named(file)
|
||||
.ok_or_else(|| anyhow::anyhow!("could not resolve {file} path (no config dir)"))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
@@ -544,19 +593,43 @@ mod imp_windows {
|
||||
.local_addr()
|
||||
.map_err(|e| anyhow::anyhow!("could not read bound port: {e}"))?
|
||||
.port();
|
||||
// Mint the token once for this daemon's lifetime; `authenticate` checks
|
||||
// against the same value. Written to the port file so a client that can
|
||||
// read it (same user) can present it back.
|
||||
let token = DAEMON_TOKEN.get_or_init(make_token);
|
||||
let contents = format!("{port}\n{}", encode_token(token));
|
||||
// Written to the marker file so a client that can read it (same user)
|
||||
// can present it back.
|
||||
let contents = format!("{port}\n{}", encode_token(&token));
|
||||
std::fs::write(&path, contents)
|
||||
.map_err(|e| anyhow::anyhow!("could not write port file {}: {e}", path.display()))?;
|
||||
Ok(listener)
|
||||
Ok((listener, token))
|
||||
}
|
||||
|
||||
/// [`connect`] to one of the daemon's other endpoints, presenting the token
|
||||
/// its marker file records. `NotFound` means nothing is listening there — the
|
||||
/// same "nobody home" every caller treats as "not running".
|
||||
pub fn connect_endpoint(file: &str) -> io::Result<Stream> {
|
||||
let (port, token) = read_port_file_named(file)
|
||||
.filter(|(p, _)| *p != 0)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("no {file} file")))?;
|
||||
let mut stream = TcpStream::connect(loopback(port))?;
|
||||
tune(&stream);
|
||||
stream.write_all(&token)?;
|
||||
Ok(stream)
|
||||
}
|
||||
|
||||
/// Remove another endpoint's marker file. Best effort, like
|
||||
/// [`remove_stale_endpoint`].
|
||||
pub fn remove_endpoint(file: &str) {
|
||||
if let Some(path) = port_path_named(file) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// A human-readable description of the endpoint, for log messages.
|
||||
pub fn endpoint_display() -> String {
|
||||
match read_port_file() {
|
||||
endpoint_display_named(PANE_PORT_FILE)
|
||||
}
|
||||
|
||||
/// [`endpoint_display`] for another of this daemon's endpoints.
|
||||
pub fn endpoint_display_named(file: &str) -> String {
|
||||
match read_port_file_named(file) {
|
||||
Some((port, _)) => format!("127.0.0.1:{port}"),
|
||||
None => "127.0.0.1:<unbound>".to_string(),
|
||||
}
|
||||
@@ -665,6 +738,61 @@ mod imp_windows {
|
||||
bad.join().unwrap();
|
||||
}
|
||||
|
||||
/// The daemon's *second* endpoint — the control dialect's, bound by
|
||||
/// [`crate::host::server`] — is a separate port with a separate token,
|
||||
/// recorded in a separate file. Two listeners, two boundaries: a client
|
||||
/// that learned the pane endpoint's token has not thereby been given the
|
||||
/// one behind which the whole workspace tree lives.
|
||||
#[test]
|
||||
fn a_second_endpoint_gets_its_own_port_and_token() {
|
||||
// The name `host::server` uses; spelled out rather than imported so
|
||||
// the transport does not depend on the dialect above it.
|
||||
const CONTROL: &str = "control.port";
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("tty7-wintok-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
config::set_config_dir(dir);
|
||||
remove_endpoint(CONTROL);
|
||||
|
||||
let (listener, token) = bind_endpoint(CONTROL).expect("bind the second endpoint");
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let recorded =
|
||||
std::fs::read_to_string(port_path_named(CONTROL).unwrap()).expect("marker file");
|
||||
let (file_port, file_token) = parse_port_file(&recorded).expect("marker file parses");
|
||||
assert_eq!(file_port, port, "the file records the port actually bound");
|
||||
assert_eq!(
|
||||
file_token, token,
|
||||
"and the token the listener will check for"
|
||||
);
|
||||
|
||||
// A client that could read the file gets in — that read is the whole
|
||||
// proof of same-user, which is what filesystem permissions give the
|
||||
// Unix socket for free.
|
||||
let good = std::thread::spawn(move || connect_endpoint(CONTROL).unwrap());
|
||||
let (mut server_side, _) = listener.accept().unwrap();
|
||||
assert!(check_endpoint_token(&mut server_side, &token).is_ok());
|
||||
let _keep = good.join().unwrap();
|
||||
|
||||
// Anything else is refused before a frame is parsed — including the
|
||||
// other endpoint's token, which is why they are minted separately.
|
||||
let mut foreign = token;
|
||||
foreign[0] ^= 0xff;
|
||||
let bad = std::thread::spawn(move || {
|
||||
let mut s = TcpStream::connect(loopback(port)).unwrap();
|
||||
let _ = s.write_all(&foreign);
|
||||
});
|
||||
let (mut server_side, _) = listener.accept().unwrap();
|
||||
assert_eq!(
|
||||
check_endpoint_token(&mut server_side, &token)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::PermissionDenied
|
||||
);
|
||||
bad.join().unwrap();
|
||||
|
||||
remove_endpoint(CONTROL);
|
||||
}
|
||||
|
||||
/// Full wiring over the real config-dir path: `bind` writes a parseable
|
||||
/// `<port>\n<token>` file and seeds the process token, and the public
|
||||
/// `authenticate` (which reads that process token) then accepts a client
|
||||
|
||||
@@ -162,7 +162,7 @@ impl RemoteHost {
|
||||
}
|
||||
|
||||
/// The underlying connection, for callers that need to speak control
|
||||
/// directly (the workspace store, once it exists).
|
||||
/// directly (the machine-tree verbs).
|
||||
pub fn client(&self) -> &Arc<ControlClient> {
|
||||
&self.client
|
||||
}
|
||||
|
||||
+649
-461
File diff suppressed because it is too large
Load Diff
@@ -26,10 +26,5 @@ tty7-core = { path = "../tty7-core" }
|
||||
# Sandboxes for the suite: an empty directory per case, removed on drop. The
|
||||
# server is on this machine, so a local temp dir is a path in its namespace.
|
||||
tempfile = "3"
|
||||
# Workspace records cross the control wire as opaque JSON, so
|
||||
# `tests/workspace_store.rs` has to build and read one. Dev-only: the binary
|
||||
# itself still depends on nothing but `tty7-core`.
|
||||
serde_json.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -144,21 +144,13 @@ fn main() -> ExitCode {
|
||||
}
|
||||
|
||||
/// Serve panes and control connections until killed.
|
||||
///
|
||||
/// The whole of it lives in [`tty7_core::daemon::server::run_daemon`], shared
|
||||
/// verbatim with `tty7 --daemon`: local and remote machines run the identical
|
||||
/// daemon, which is what makes "one machine = one daemon = one workspace tree"
|
||||
/// a fact rather than a convention.
|
||||
fn run_daemon() -> ExitCode {
|
||||
// Control first, and on its own thread: a machine that cannot host panes
|
||||
// (no pty, a locked-down container) should still be able to back a remote
|
||||
// workspace's files, so a control failure is reported and stepped over
|
||||
// rather than being fatal.
|
||||
#[cfg(unix)]
|
||||
match tty7_core::host::server::spawn_control_listener_with(
|
||||
tty7_core::host::local::LocalHost::shared(),
|
||||
control_services(),
|
||||
) {
|
||||
Ok(path) => eprintln!("tty7-server: control socket at {}", path.display()),
|
||||
Err(e) => eprintln!("tty7-server: control listener unavailable: {e}"),
|
||||
}
|
||||
|
||||
if let Err(e) = tty7_core::daemon::server::run() {
|
||||
if let Err(e) = tty7_core::daemon::server::run_daemon() {
|
||||
eprintln!("tty7-server: daemon exited with error: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
@@ -226,7 +218,7 @@ fn run_stdio(args: &[String]) -> io::Result<()> {
|
||||
// the same rule `bridge_panes` follows one dialect over,
|
||||
// and for the same reason. Two `--stdio` sessions both
|
||||
// falling through to serving in-process would each hold
|
||||
// their own `WorkspaceStore` over the one file, and
|
||||
// their own `MachineStore` over the one file, and
|
||||
// `persist` writes the whole document: the second to save
|
||||
// silently drops the first's changes. Their attachment
|
||||
// registries would be separate too, which makes design
|
||||
@@ -266,7 +258,11 @@ fn run_stdio(args: &[String]) -> io::Result<()> {
|
||||
// Takes stdin/stdout away from the rest of the process before a
|
||||
// single frame is written — see `StdioDuplex::take`.
|
||||
let link = StdioDuplex::take()?;
|
||||
server::serve_with(link, LocalHost::shared(), control_services())
|
||||
server::serve_with(
|
||||
link,
|
||||
LocalHost::shared(),
|
||||
tty7_core::daemon::server::control_services(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,37 +373,6 @@ fn bridge(upstream: std::os::unix::net::UnixStream) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What this machine offers over a control connection, beyond its filesystem.
|
||||
///
|
||||
/// The workspace store is the reason this binary exists on a remote box at all:
|
||||
/// the workspace list, the tab/pane tree and each pane's cwd live on
|
||||
/// **the machine the panes run on**, so that connecting from a different laptop
|
||||
/// shows the same thing. The client's `session.json` keeps only its own view
|
||||
/// state.
|
||||
///
|
||||
/// A machine with no home directory to place the file in still serves files and
|
||||
/// panes — it simply says `workspace-store` is not among its capabilities, and
|
||||
/// clients see the same "does not serve the workspace store" answer a
|
||||
/// pre-M5 server gives.
|
||||
fn control_services() -> tty7_core::host::server::Services {
|
||||
use tty7_core::core::workspace_store::WorkspaceStore;
|
||||
match WorkspaceStore::shared() {
|
||||
Ok(store) => {
|
||||
log_stderr(format_args!(
|
||||
"workspace store at {}",
|
||||
store.path().display()
|
||||
));
|
||||
tty7_core::host::server::Services::with_workspaces(store)
|
||||
}
|
||||
Err(e) => {
|
||||
log_stderr(format_args!(
|
||||
"no workspace store ({e}); serving files and panes only"
|
||||
));
|
||||
tty7_core::host::server::Services::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `--flag <value>` or `--flag=<value>`, first occurrence wins.
|
||||
/// Whether a failed control probe may start the machine's daemon.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
//! The machine-owned workspace tree, end to end against a real `tty7-server`
|
||||
//! child process.
|
||||
//!
|
||||
//! The client is the shipped `ControlClient`, the wire is the control dialect
|
||||
//! over real pipes, and the server is the shipped binary owning its tree in a
|
||||
//! file. What the process boundary buys here specifically:
|
||||
//!
|
||||
//! | | Why an in-process store would not do |
|
||||
//! |---|---|
|
||||
//! | The tree is on **the server's** disk | The whole design is "the daemon owns the structure"; a store in the test's address space proves the data type, not the ownership |
|
||||
//! | `machine-tree` is advertised only when served | The capability bit is built from what the *binary* wires up |
|
||||
//! | A delta reaches the **other** connection, never the writer | Origin exclusion is the contract that lets a client apply its own edit from the reply and everyone else's from the push |
|
||||
//!
|
||||
//! Every case gets its own `$TTY7_DATA_DIR`, so no case can be explained by
|
||||
//! another's leftovers and nothing here can touch a developer's real tree.
|
||||
|
||||
// Unix-only: the server under test is a `--stdio` child, and the two-client
|
||||
// case stands up a control socket.
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tty7_core::core::machine::{Axis, LayoutDelta, MACHINE_FILE, PaneNode, PaneSeed};
|
||||
use tty7_core::daemon::control::{
|
||||
ControlClient, ControlEvent, ControlHello, ControlRequest, LinkShutdown, ReplyOk, WorkspaceId,
|
||||
feature,
|
||||
};
|
||||
|
||||
/// The child, and the only way to end it — a process-backed link is reaped by
|
||||
/// its `LinkShutdown`, exactly as in `stdio_conformance.rs`.
|
||||
struct ServerProcess {
|
||||
child: Mutex<Option<Child>>,
|
||||
}
|
||||
|
||||
impl LinkShutdown for ServerProcess {
|
||||
fn shutdown_link(&self) -> io::Result<()> {
|
||||
let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One connected client: the RPC channel, plus everything the server pushed.
|
||||
struct Client {
|
||||
control: ControlClient,
|
||||
events: Arc<Mutex<Vec<ControlEvent>>>,
|
||||
peer_features: Vec<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Wait for a `Layout` delta about `workspace` matching `want`, or fail
|
||||
/// saying what did arrive. Polled because a push and the reply that caused
|
||||
/// it race by construction.
|
||||
fn expect_delta(&self, workspace: WorkspaceId, want: impl Fn(&LayoutDelta) -> bool) {
|
||||
let key = workspace.to_string();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let seen = self
|
||||
.events
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
if seen.iter().any(|e| {
|
||||
matches!(e, ControlEvent::Layout { workspace: w, delta } if *w == key && want(delta))
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"no matching Layout delta for {key}; saw {seen:?}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn delta_count(&self) -> usize {
|
||||
self.events
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.filter(|e| matches!(e, ControlEvent::Layout { .. }))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a `tty7-server --stdio --serve` whose tree lives in `data_dir`, and
|
||||
/// connect a client to it. `--serve` for the same reason as everywhere else in
|
||||
/// these tests: a developer's real daemon must never be bridged into.
|
||||
fn connect(data_dir: &Path, token: &str) -> Client {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
|
||||
.args(["--stdio", "--serve"])
|
||||
.env("TTY7_DATA_DIR", data_dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("could not start tty7-server --stdio");
|
||||
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let stdin = child.stdin.take().expect("piped");
|
||||
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess {
|
||||
child: Mutex::new(Some(child)),
|
||||
});
|
||||
|
||||
let events: Arc<Mutex<Vec<ControlEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&events);
|
||||
let control = ControlClient::connect_with(
|
||||
stdout,
|
||||
stdin,
|
||||
Some(closer),
|
||||
&ControlHello::host_rpc(token, "test-client"),
|
||||
Box::new(move |event| sink.lock().unwrap_or_else(|e| e.into_inner()).push(event)),
|
||||
)
|
||||
.expect("handshake with tty7-server --stdio");
|
||||
|
||||
let peer_features = control.hello().features.clone();
|
||||
Client {
|
||||
control,
|
||||
events,
|
||||
peer_features,
|
||||
}
|
||||
}
|
||||
|
||||
fn data_dir() -> tempfile::TempDir {
|
||||
tempfile::TempDir::new().unwrap()
|
||||
}
|
||||
|
||||
fn machine_file(dir: &tempfile::TempDir) -> PathBuf {
|
||||
dir.path().join(MACHINE_FILE)
|
||||
}
|
||||
|
||||
fn seed(pane: u64, cwd: &str) -> PaneSeed {
|
||||
PaneSeed {
|
||||
pane,
|
||||
cwd: Some(cwd.to_string()),
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The capability bit is the client's cue that the tree verbs are worth a
|
||||
/// round trip, and it has to reflect what the shipped binary wired up.
|
||||
#[test]
|
||||
fn the_server_advertises_the_machine_tree() {
|
||||
let dir = data_dir();
|
||||
let client = connect(dir.path(), "cap");
|
||||
assert!(
|
||||
client
|
||||
.peer_features
|
||||
.iter()
|
||||
.any(|f| f == feature::MACHINE_TREE),
|
||||
"features were {:?}",
|
||||
client.peer_features
|
||||
);
|
||||
}
|
||||
|
||||
/// The semantic operations against a real server, and the tree ends up in a
|
||||
/// file that server owns. This is "the daemon owns the structure" as a
|
||||
/// syscall someone else made, not as a diagram.
|
||||
#[test]
|
||||
fn the_tree_is_built_by_operations_and_lives_in_the_servers_file() {
|
||||
let dir = data_dir();
|
||||
let client = connect(dir.path(), "ops");
|
||||
|
||||
// Build: a workspace, a tab, a split.
|
||||
let ws = match client
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceCreate {
|
||||
name: Some("api".into()),
|
||||
workspace: None,
|
||||
})
|
||||
.expect("create workspace")
|
||||
{
|
||||
ReplyOk::WorkspaceTree(ws) => *ws,
|
||||
other => panic!("expected WorkspaceTree, got {other:?}"),
|
||||
};
|
||||
let tab = match client
|
||||
.control
|
||||
.call(ControlRequest::TabCreate {
|
||||
workspace: ws.id,
|
||||
at: None,
|
||||
pane: seed(1, "/home/me/proj"),
|
||||
tab: None,
|
||||
})
|
||||
.expect("create tab")
|
||||
{
|
||||
ReplyOk::TabTree(tab) => *tab,
|
||||
other => panic!("expected TabTree, got {other:?}"),
|
||||
};
|
||||
client
|
||||
.control
|
||||
.call(ControlRequest::PaneSplit {
|
||||
workspace: ws.id,
|
||||
pane: 1,
|
||||
axis: Axis::Vertical,
|
||||
ratio: 0.3,
|
||||
new: seed(2, "/home/me/proj/sub"),
|
||||
first: false,
|
||||
})
|
||||
.expect("split");
|
||||
|
||||
// Read back through the wire.
|
||||
let machine = match client.control.call(ControlRequest::MachineGet).unwrap() {
|
||||
ReplyOk::MachineTree(m) => *m,
|
||||
other => panic!("expected MachineTree, got {other:?}"),
|
||||
};
|
||||
assert_eq!(machine.workspaces.len(), 1);
|
||||
assert_eq!(machine.workspaces[0].tabs[0].id, tab.id);
|
||||
assert_eq!(machine.workspaces[0].tabs[0].root.pane_ids(), vec![1, 2]);
|
||||
assert_eq!(machine.panes.len(), 2);
|
||||
assert!(
|
||||
machine.panes.iter().all(|p| p.live),
|
||||
"panes this server was told about in its own lifetime are live"
|
||||
);
|
||||
|
||||
// The file is the server's: the test process never wrote it.
|
||||
let text = std::fs::read_to_string(machine_file(&dir)).expect("the server wrote its tree");
|
||||
assert!(text.contains(&ws.id.to_string()), "{text}");
|
||||
|
||||
// A refusal is a client-visible error, not a dropped reply.
|
||||
let missing = client
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceTree {
|
||||
workspace: WorkspaceId::new(),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(missing.kind(), io::ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
/// **The revival contract, across a real restart.** A second server process
|
||||
/// reads the first one's tree; every pane in it is dead (`live == false`), the
|
||||
/// leaves still name them, and `PaneReplace` rebinds a leaf to a successor.
|
||||
#[test]
|
||||
fn a_new_server_process_reports_the_old_panes_dead_and_accepts_their_successors() {
|
||||
let dir = data_dir();
|
||||
let ws = {
|
||||
let first = connect(dir.path(), "first");
|
||||
let ws = match first
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceCreate {
|
||||
name: None,
|
||||
workspace: None,
|
||||
})
|
||||
.unwrap()
|
||||
{
|
||||
ReplyOk::WorkspaceTree(ws) => *ws,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
first
|
||||
.control
|
||||
.call(ControlRequest::TabCreate {
|
||||
workspace: ws.id,
|
||||
at: None,
|
||||
pane: seed(7, "/home/me/proj"),
|
||||
tab: None,
|
||||
})
|
||||
.unwrap();
|
||||
first.control.close();
|
||||
ws
|
||||
};
|
||||
|
||||
// A brand-new server process over the same file.
|
||||
let second = connect(dir.path(), "second");
|
||||
let machine = match second.control.call(ControlRequest::MachineGet).unwrap() {
|
||||
ReplyOk::MachineTree(m) => *m,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let record = machine
|
||||
.panes
|
||||
.iter()
|
||||
.find(|p| p.id == 7)
|
||||
.expect("the pane record survives the restart");
|
||||
assert!(!record.live, "a restarted server has no live panes");
|
||||
assert_eq!(
|
||||
record.cwd.as_deref(),
|
||||
Some("/home/me/proj"),
|
||||
"the facts a successor spawns from survive"
|
||||
);
|
||||
assert_eq!(
|
||||
machine.workspaces[0].tabs[0].root,
|
||||
PaneNode::Leaf { pane: 7 },
|
||||
"the leaf still names the dead pane — the revival slot"
|
||||
);
|
||||
|
||||
// Revive: a fresh pane takes the leaf, the spent record goes.
|
||||
second
|
||||
.control
|
||||
.call(ControlRequest::PaneReplace {
|
||||
workspace: ws.id,
|
||||
old: 7,
|
||||
new: seed(1, "/home/me/proj"),
|
||||
})
|
||||
.expect("replace");
|
||||
let machine = match second.control.call(ControlRequest::MachineGet).unwrap() {
|
||||
ReplyOk::MachineTree(m) => *m,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert_eq!(
|
||||
machine.workspaces[0].tabs[0].root,
|
||||
PaneNode::Leaf { pane: 1 }
|
||||
);
|
||||
assert!(machine.panes.iter().all(|p| p.id != 7));
|
||||
}
|
||||
|
||||
/// Two clients on one server. An operation by one reaches the other as a
|
||||
/// `Layout` delta and never comes back to its author — the mechanism that
|
||||
/// replaces whole-record last-writer-wins with edits that all land.
|
||||
#[test]
|
||||
fn an_operation_from_one_client_reaches_the_other_as_a_delta() {
|
||||
use tty7_core::host::local::LocalHost;
|
||||
use tty7_core::host::server;
|
||||
|
||||
let dir = data_dir();
|
||||
let machine = tty7_core::core::machine::MachineStore::open(machine_file(&dir));
|
||||
let sock = dir.path().join("control.sock");
|
||||
let listener = server::bind_control_socket(&sock).unwrap();
|
||||
{
|
||||
let machine = Arc::clone(&machine);
|
||||
std::thread::spawn(move || {
|
||||
server::serve_listener_with(
|
||||
listener,
|
||||
LocalHost::new(),
|
||||
server::Services::with_machine(machine),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
let writer = bridged(&sock, "writer");
|
||||
let watcher = bridged(&sock, "watcher");
|
||||
assert!(
|
||||
writer
|
||||
.peer_features
|
||||
.iter()
|
||||
.any(|f| f == feature::MACHINE_TREE)
|
||||
);
|
||||
// Make sure the watcher's subscription is up (its server thread subscribes
|
||||
// before answering its first request).
|
||||
watcher.control.call(ControlRequest::Ping).unwrap();
|
||||
|
||||
let ws = match writer
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceCreate {
|
||||
name: Some("shared".into()),
|
||||
workspace: None,
|
||||
})
|
||||
.unwrap()
|
||||
{
|
||||
ReplyOk::WorkspaceTree(ws) => *ws,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
let tab = match writer
|
||||
.control
|
||||
.call(ControlRequest::TabCreate {
|
||||
workspace: ws.id,
|
||||
at: None,
|
||||
pane: seed(3, "/srv"),
|
||||
tab: None,
|
||||
})
|
||||
.unwrap()
|
||||
{
|
||||
ReplyOk::TabTree(tab) => *tab,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
|
||||
watcher.expect_delta(
|
||||
ws.id,
|
||||
|d| matches!(d, LayoutDelta::WorkspaceCreated { workspace } if workspace.id == ws.id),
|
||||
);
|
||||
watcher.expect_delta(
|
||||
ws.id,
|
||||
|d| matches!(d, LayoutDelta::TabCreated { tab: t, .. } if t.id == tab.id),
|
||||
);
|
||||
// The created tab became active, and the *change of active tab* is its own
|
||||
// delta — implicit activation must not be something a client re-derives.
|
||||
watcher.expect_delta(
|
||||
ws.id,
|
||||
|d| matches!(d, LayoutDelta::ActiveTabChanged { tab: t } if *t == tab.id),
|
||||
);
|
||||
assert_eq!(
|
||||
writer.delta_count(),
|
||||
0,
|
||||
"a client must not be pushed its own operation"
|
||||
);
|
||||
|
||||
// …and the rule holds in the other direction.
|
||||
watcher
|
||||
.control
|
||||
.call(ControlRequest::TabRename {
|
||||
workspace: ws.id,
|
||||
tab: tab.id,
|
||||
name: Some("build".into()),
|
||||
})
|
||||
.unwrap();
|
||||
writer.expect_delta(
|
||||
ws.id,
|
||||
|d| matches!(d, LayoutDelta::TabRenamed { name: Some(n), .. } if n == "build"),
|
||||
);
|
||||
assert_eq!(watcher.delta_count(), 3, "still only the writer's own ops");
|
||||
}
|
||||
|
||||
/// Takeover semantics on the new tree, with **no record store served at
|
||||
/// all**: the attach verbs predate the tree, and their contract — newcomer
|
||||
/// wins, the displaced session is told, a stale detach cannot evict the
|
||||
/// usurper — must survive the record store's retirement.
|
||||
#[test]
|
||||
fn attachment_rides_the_tree_when_no_record_store_is_served() {
|
||||
use tty7_core::host::local::LocalHost;
|
||||
use tty7_core::host::server;
|
||||
|
||||
let dir = data_dir();
|
||||
let machine = tty7_core::core::machine::MachineStore::open(machine_file(&dir));
|
||||
let sock = dir.path().join("control.sock");
|
||||
let listener = server::bind_control_socket(&sock).unwrap();
|
||||
{
|
||||
let machine = Arc::clone(&machine);
|
||||
std::thread::spawn(move || {
|
||||
server::serve_listener_with(
|
||||
listener,
|
||||
LocalHost::new(),
|
||||
server::Services::with_machine(machine),
|
||||
)
|
||||
});
|
||||
}
|
||||
let ws = machine
|
||||
.workspace_create(None, Some("shared".into()), None)
|
||||
.unwrap();
|
||||
|
||||
let laptop = bridged(&sock, "laptop");
|
||||
let desktop = bridged(&sock, "desktop");
|
||||
|
||||
let attach = |client: &Client| {
|
||||
client.control.call(ControlRequest::WorkspaceAttach {
|
||||
id: ws.id.to_string(),
|
||||
})
|
||||
};
|
||||
match attach(&laptop).expect("first attach") {
|
||||
ReplyOk::Attached { took_over_from } => assert_eq!(took_over_from, None),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
machine.attachment(ws.id).map(|a| a.hostname),
|
||||
Some("laptop".into()),
|
||||
"the tree's own record says who holds the workspace"
|
||||
);
|
||||
|
||||
// The newcomer wins, learns whom it displaced, and the displaced session
|
||||
// is pushed a Preempted notice.
|
||||
match attach(&desktop).expect("takeover") {
|
||||
ReplyOk::Attached { took_over_from } => {
|
||||
assert_eq!(took_over_from.as_deref(), Some("laptop"));
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let seen = laptop.events.lock().unwrap().clone();
|
||||
if seen.iter().any(|e| {
|
||||
matches!(e, ControlEvent::Preempted { workspace, by }
|
||||
if *workspace == ws.id.to_string() && by == "desktop")
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "no Preempted push; saw {seen:?}");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
|
||||
// The preempted session tidying up must not evict the usurper.
|
||||
laptop
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceDetach {
|
||||
id: ws.id.to_string(),
|
||||
})
|
||||
.expect("a stale detach is success, not eviction");
|
||||
assert_eq!(
|
||||
machine.attachment(ws.id).map(|a| a.hostname),
|
||||
Some("desktop".into())
|
||||
);
|
||||
}
|
||||
|
||||
/// A `--stdio --bridge` child connected to an already-listening control
|
||||
/// socket — the two-hop shape a real multi-client machine has.
|
||||
fn bridged(sock: &Path, token: &str) -> Client {
|
||||
let hello = ControlHello::host_rpc(token, token);
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
|
||||
.args(["--stdio", "--bridge", "--control-sock"])
|
||||
.arg(sock)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("could not start the bridging client");
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let stdin = child.stdin.take().expect("piped");
|
||||
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess {
|
||||
child: Mutex::new(Some(child)),
|
||||
});
|
||||
let events: Arc<Mutex<Vec<ControlEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&events);
|
||||
let control = ControlClient::connect_with(
|
||||
stdout,
|
||||
stdin,
|
||||
Some(closer),
|
||||
&hello,
|
||||
Box::new(move |e| sink.lock().unwrap_or_else(|e| e.into_inner()).push(e)),
|
||||
)
|
||||
.expect("bridge handshake");
|
||||
let peer_features = control.hello().features.clone();
|
||||
Client {
|
||||
control,
|
||||
events,
|
||||
peer_features,
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ fn stdio_host() -> (SharedHost, TempSandbox) {
|
||||
// bridge to *that* would be testing their machine's state instead of
|
||||
// this build.
|
||||
.args(["--stdio", "--serve"])
|
||||
// The server opens its workspace store at startup. None of these cases
|
||||
// The server opens its machine tree at startup. None of these cases
|
||||
// touch it, but pointing it at the sandbox keeps forty-six child
|
||||
// processes off the developer's real `~/.local/share/tty7`.
|
||||
.env("TTY7_DATA_DIR", sandbox.path())
|
||||
|
||||
@@ -1,543 +0,0 @@
|
||||
//! The workspace store, end to end against a real `tty7-server` child process.
|
||||
//!
|
||||
//! Same shape and the same reasoning as [`stdio_conformance`]: the client is
|
||||
//! the shipped `ControlClient`, the wire is the control dialect over real
|
||||
//! pipes, and the server is the shipped binary keeping its records in a file it
|
||||
//! owns. What this file adds is the half the conformance suite cannot reach —
|
||||
//! the store is not a `Host` method, so no amount of `read_dir` parity proves
|
||||
//! that `WorkspacePut` reached a disk or that another client heard about it.
|
||||
//!
|
||||
//! The three things worth a process boundary:
|
||||
//!
|
||||
//! | | Why an in-process socket pair would not do |
|
||||
//! |---|---|
|
||||
//! | The record is on **the server's** disk | The whole storage split is "the machine is the authority". A store in the test's own address space proves nothing about that |
|
||||
//! | `workspace-store` is advertised only when served | The capability bit is built from what the *binary* wires up, and that wiring lives in `main.rs` |
|
||||
//! | A change reaches the **other** connection | Two clients, one server process, one file — the configuration the user actually has when their laptop and their desktop are both connected |
|
||||
//!
|
||||
//! Every case gets its own `$TTY7_DATA_DIR`, so no case can be explained by
|
||||
//! another's leftovers and nothing here can touch the developer's real
|
||||
//! `~/.local/share/tty7/workspaces.json`.
|
||||
|
||||
// Unix-only, for the same reason as `stdio_conformance.rs`: the server under
|
||||
// test is a `--stdio` child, and two of the cases stand up a control socket.
|
||||
#![cfg(unix)]
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tty7_core::core::workspace_store::{STORE_FILE, WorkspaceStore};
|
||||
use tty7_core::daemon::control::{
|
||||
ControlClient, ControlEvent, ControlHello, ControlRequest, LinkShutdown, ReplyOk, feature,
|
||||
};
|
||||
use tty7_core::host::local::LocalHost;
|
||||
use tty7_core::host::server;
|
||||
|
||||
/// The child, and the only way to end it — see `stdio_conformance.rs` for why a
|
||||
/// `LinkShutdown` is what reaps a process-backed link.
|
||||
struct ServerProcess {
|
||||
child: Mutex<Option<Child>>,
|
||||
}
|
||||
|
||||
impl LinkShutdown for ServerProcess {
|
||||
fn shutdown_link(&self) -> io::Result<()> {
|
||||
let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() else {
|
||||
return Ok(());
|
||||
};
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One connected client: the RPC channel, plus everything the server pushed to
|
||||
/// it.
|
||||
struct Client {
|
||||
control: ControlClient,
|
||||
events: Arc<Mutex<Vec<ControlEvent>>>,
|
||||
peer_features: Vec<String>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
/// Wait for a `WorkspaceChanged` naming `id`, or fail saying what did
|
||||
/// arrive. Polled rather than blocked on a channel because the event and
|
||||
/// the reply that caused it race by construction.
|
||||
fn expect_changed(&self, id: &str) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let seen = self
|
||||
.events
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
if seen
|
||||
.iter()
|
||||
.any(|e| matches!(e, ControlEvent::WorkspaceChanged { id: got } if got == id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"no WorkspaceChanged for {id}; saw {seen:?}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for the takeover notice naming `workspace` and `by`.
|
||||
fn expect_preempted(&self, workspace: &str, by: &str) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let seen = self
|
||||
.events
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
if seen.iter().any(|e| {
|
||||
matches!(e, ControlEvent::Preempted { workspace: w, by: b }
|
||||
if w == workspace && b == by)
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"no Preempted for {workspace} by {by}; saw {seen:?}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn changed_count(&self) -> usize {
|
||||
self.events
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.filter(|e| matches!(e, ControlEvent::WorkspaceChanged { .. }))
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a `tty7-server --stdio --serve` whose store lives in `data_dir`, and
|
||||
/// connect a client to it.
|
||||
///
|
||||
/// `--serve` rather than letting the mode be probed: a developer running these
|
||||
/// tests may well have a real `tty7-server --daemon` up, and bridging to *that*
|
||||
/// would be testing their machine's state — and, here, writing to their real
|
||||
/// workspace file.
|
||||
fn connect(data_dir: &Path, token: &str) -> Client {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
|
||||
.args(["--stdio", "--serve"])
|
||||
.env("TTY7_DATA_DIR", data_dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("could not start tty7-server --stdio");
|
||||
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let stdin = child.stdin.take().expect("piped");
|
||||
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess {
|
||||
child: Mutex::new(Some(child)),
|
||||
});
|
||||
|
||||
let events: Arc<Mutex<Vec<ControlEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&events);
|
||||
let control = ControlClient::connect_with(
|
||||
stdout,
|
||||
stdin,
|
||||
Some(closer),
|
||||
&ControlHello::host_rpc(token, "test-client"),
|
||||
Box::new(move |event| sink.lock().unwrap_or_else(|e| e.into_inner()).push(event)),
|
||||
)
|
||||
.expect("handshake with tty7-server --stdio");
|
||||
|
||||
let peer_features = control.hello().features.clone();
|
||||
Client {
|
||||
control,
|
||||
events,
|
||||
peer_features,
|
||||
}
|
||||
}
|
||||
|
||||
fn data_dir() -> tempfile::TempDir {
|
||||
tempfile::TempDir::new().unwrap()
|
||||
}
|
||||
|
||||
fn store_file(dir: &tempfile::TempDir) -> PathBuf {
|
||||
dir.path().join(STORE_FILE)
|
||||
}
|
||||
|
||||
fn record(id: &str, name: &str) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"session": {"active": 0, "tabs": [
|
||||
{"pane": {"Leaf": {"cwd": "/home/me/proj", "pane_id": 11}},
|
||||
"sidebar_group": "/home/me/proj"}
|
||||
]},
|
||||
"last_active": 1_753_600_000u64,
|
||||
})
|
||||
}
|
||||
|
||||
fn json(reply: ReplyOk) -> serde_json::Value {
|
||||
match reply {
|
||||
ReplyOk::Json(v) => v,
|
||||
other => panic!("expected a Json reply, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The capability bit is the client's cue that asking is worth a round trip, so
|
||||
/// it has to reflect what the shipped binary actually wired up.
|
||||
#[test]
|
||||
fn the_server_advertises_the_workspace_store() {
|
||||
let dir = data_dir();
|
||||
let client = connect(dir.path(), "cap");
|
||||
assert!(
|
||||
client
|
||||
.peer_features
|
||||
.iter()
|
||||
.any(|f| f == feature::WORKSPACE_STORE),
|
||||
"features were {:?}",
|
||||
client.peer_features
|
||||
);
|
||||
}
|
||||
|
||||
/// **The milestone's proof for M5.** The four RPCs against a real server, and
|
||||
/// the record ends up in a file that server owns — the storage split is not a
|
||||
/// diagram, it is this file on that machine.
|
||||
#[test]
|
||||
fn records_survive_in_a_file_the_server_owns() {
|
||||
let dir = data_dir();
|
||||
let client = connect(dir.path(), "rpc");
|
||||
|
||||
assert_eq!(
|
||||
json(client.control.call(ControlRequest::WorkspaceList).unwrap()),
|
||||
serde_json::json!([])
|
||||
);
|
||||
|
||||
for (id, name) in [("w-api", "api"), ("w-web", "web")] {
|
||||
client
|
||||
.control
|
||||
.call(ControlRequest::WorkspacePut {
|
||||
id: id.to_string(),
|
||||
json: record(id, name),
|
||||
})
|
||||
.expect("put");
|
||||
}
|
||||
|
||||
// The file is on this machine only because the "remote" is this machine;
|
||||
// the point is that the *test process* never wrote it. Reading it with
|
||||
// plain `std::fs` is how we know the bytes went out through a pipe and came
|
||||
// back as a syscall someone else made.
|
||||
let text = std::fs::read_to_string(store_file(&dir)).expect("the server wrote its store");
|
||||
assert!(text.contains("w-api"), "{text}");
|
||||
assert!(text.contains("w-web"), "{text}");
|
||||
|
||||
// Get answers exactly what was put.
|
||||
let got = json(
|
||||
client
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceGet {
|
||||
id: "w-api".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(got, record("w-api", "api"));
|
||||
|
||||
// List answers both, in the order they were written.
|
||||
let listed = json(client.control.call(ControlRequest::WorkspaceList).unwrap());
|
||||
let ids: Vec<&str> = listed
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v["id"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(ids, vec!["w-api", "w-web"]);
|
||||
|
||||
// A missing id is an error the client can tell from an empty record.
|
||||
let missing = client
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceGet {
|
||||
id: "not-a-workspace".to_string(),
|
||||
})
|
||||
.unwrap_err();
|
||||
assert_eq!(missing.kind(), io::ErrorKind::NotFound);
|
||||
|
||||
// Delete reaches the disk, and deleting again is still success.
|
||||
for _ in 0..2 {
|
||||
client
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceDelete {
|
||||
id: "w-api".to_string(),
|
||||
})
|
||||
.expect("delete");
|
||||
}
|
||||
let text = std::fs::read_to_string(store_file(&dir)).unwrap();
|
||||
assert!(!text.contains("w-api"), "{text}");
|
||||
assert!(text.contains("w-web"), "{text}");
|
||||
}
|
||||
|
||||
/// A second connection to the same server sees the first one's records — that
|
||||
/// is what "换台电脑连过来要看到同一份" means once the machine is fixed and the
|
||||
/// client is not.
|
||||
#[test]
|
||||
fn a_later_client_sees_what_an_earlier_one_wrote() {
|
||||
let dir = data_dir();
|
||||
{
|
||||
let first = connect(dir.path(), "first");
|
||||
first
|
||||
.control
|
||||
.call(ControlRequest::WorkspacePut {
|
||||
id: "w".to_string(),
|
||||
json: record("w", "api"),
|
||||
})
|
||||
.expect("put");
|
||||
first.control.close();
|
||||
}
|
||||
|
||||
// A brand-new server process, reading the file the previous one left.
|
||||
let second = connect(dir.path(), "second");
|
||||
let got = json(
|
||||
second
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceGet {
|
||||
id: "w".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(got["name"], "api");
|
||||
assert_eq!(got["session"]["tabs"][0]["pane"]["Leaf"]["pane_id"], 11);
|
||||
}
|
||||
|
||||
/// Two clients on one machine at once. A change by one has to reach the other,
|
||||
/// and must not come back to its author.
|
||||
///
|
||||
/// The store lives behind a listener, as it does under `--daemon`, and both
|
||||
/// clients reach it as `--stdio --bridge` children — the same two-hop shape
|
||||
/// `cli.rs` uses, and the configuration a user has when their laptop and their
|
||||
/// desktop are both connected. A store per connection would pass every other
|
||||
/// test in this file and fail this one.
|
||||
#[test]
|
||||
fn a_change_from_one_client_reaches_the_other() {
|
||||
let dir = data_dir();
|
||||
let store = WorkspaceStore::open(store_file(&dir));
|
||||
let sock = dir.path().join("control.sock");
|
||||
let listener = server::bind_control_socket(&sock).unwrap();
|
||||
{
|
||||
let store = Arc::clone(&store);
|
||||
std::thread::spawn(move || {
|
||||
server::serve_listener_with(
|
||||
listener,
|
||||
LocalHost::new(),
|
||||
server::Services::with_workspaces(store),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
let writer = bridged(&sock, "writer");
|
||||
let watcher = bridged(&sock, "watcher");
|
||||
assert!(
|
||||
writer
|
||||
.peer_features
|
||||
.iter()
|
||||
.any(|f| f == feature::WORKSPACE_STORE)
|
||||
);
|
||||
|
||||
writer
|
||||
.control
|
||||
.call(ControlRequest::WorkspacePut {
|
||||
id: "shared".to_string(),
|
||||
json: record("shared", "api"),
|
||||
})
|
||||
.expect("put");
|
||||
|
||||
watcher.expect_changed("shared");
|
||||
assert_eq!(
|
||||
writer.changed_count(),
|
||||
0,
|
||||
"a client must not be pushed its own change"
|
||||
);
|
||||
|
||||
// The watcher is looking at the same store, not at a copy.
|
||||
let got = json(
|
||||
watcher
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceGet {
|
||||
id: "shared".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(got["name"], "api");
|
||||
|
||||
// A delete is a change too — and the watcher's own delete comes back to the
|
||||
// writer, which is the same rule seen from the other side.
|
||||
watcher
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceDelete {
|
||||
id: "shared".to_string(),
|
||||
})
|
||||
.expect("delete");
|
||||
writer.expect_changed("shared");
|
||||
assert_eq!(watcher.changed_count(), 1, "still only the writer's put");
|
||||
assert_eq!(store.len(), 0);
|
||||
}
|
||||
|
||||
/// **The takeover, across two real processes.**
|
||||
///
|
||||
/// The same two-client shape as the change-notification test, and for the same
|
||||
/// reason: a takeover is by definition something one connection does to
|
||||
/// *another*, so an in-process registry with two handles into it would prove
|
||||
/// only that the data structure works. What has to hold is that the notice
|
||||
/// crosses a pipe into a different program and that the displaced link actually
|
||||
/// closes.
|
||||
///
|
||||
/// D8 is the assertion in the middle: the newcomer holds the workspace
|
||||
/// afterwards. Rejecting the second client would satisfy "only one at a time"
|
||||
/// just as well and is the decision this test exists to rule out.
|
||||
#[test]
|
||||
fn a_later_client_takes_the_workspace_and_the_first_is_cut_off() {
|
||||
let dir = data_dir();
|
||||
let store = WorkspaceStore::open(store_file(&dir));
|
||||
let sock = dir.path().join("control.sock");
|
||||
let listener = server::bind_control_socket(&sock).unwrap();
|
||||
{
|
||||
let store = Arc::clone(&store);
|
||||
std::thread::spawn(move || {
|
||||
server::serve_listener_with(
|
||||
listener,
|
||||
LocalHost::new(),
|
||||
server::Services::with_workspaces(store),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
let laptop = bridged_for(&sock, "tok-laptop", "laptop", Some("w"));
|
||||
// The attach runs on the server thread after the handshake reply, so the
|
||||
// record is what says it happened — not the fact that we got a `HELLO_OK`.
|
||||
await_attachment(&store, "w", "laptop");
|
||||
assert!(laptop.control.call(ControlRequest::Ping).is_ok());
|
||||
|
||||
let desktop = bridged_for(&sock, "tok-desktop", "desktop", Some("w"));
|
||||
|
||||
// The displaced client is told which workspace it lost and to whom.
|
||||
laptop.expect_preempted("w", "desktop");
|
||||
// …and then its link is closed, because this connection existed for that
|
||||
// workspace: the server closes its stream.
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while laptop.control.is_connected() {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"the displaced session's link stayed open"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert_eq!(
|
||||
laptop
|
||||
.control
|
||||
.call(ControlRequest::Ping)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
io::ErrorKind::ConnectionReset
|
||||
);
|
||||
|
||||
// D8: the newcomer is the one holding it, and it can still work.
|
||||
await_attachment(&store, "w", "desktop");
|
||||
assert!(desktop.control.call(ControlRequest::Ping).is_ok());
|
||||
assert_eq!(
|
||||
desktop.changed_count(),
|
||||
0,
|
||||
"taking over is not a workspace change"
|
||||
);
|
||||
|
||||
// Taking it back is the same operation in the other direction — that is all
|
||||
// the [Take Back] button is.
|
||||
let back = bridged_for(&sock, "tok-laptop-2", "laptop", None);
|
||||
let reply = back
|
||||
.control
|
||||
.call(ControlRequest::WorkspaceAttach { id: "w".into() })
|
||||
.expect("attach");
|
||||
assert_eq!(
|
||||
reply,
|
||||
ReplyOk::Attached {
|
||||
took_over_from: Some("desktop".to_string())
|
||||
}
|
||||
);
|
||||
desktop.expect_preempted("w", "laptop");
|
||||
await_attachment(&store, "w", "laptop");
|
||||
|
||||
// The link that just did the taking was not opened *for* the workspace, so
|
||||
// it is a plain machine connection and keeps working — that is the shape the
|
||||
// GUI has, one link per machine.
|
||||
assert!(back.control.is_connected());
|
||||
assert!(back.control.call(ControlRequest::Ping).is_ok());
|
||||
}
|
||||
|
||||
/// Poll until `hostname` holds `workspace`, or fail saying who does.
|
||||
fn await_attachment(store: &Arc<WorkspaceStore>, workspace: &str, hostname: &str) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let who = store.attachment(workspace);
|
||||
if who.as_ref().map(|a| a.hostname.as_str()) == Some(hostname) {
|
||||
return;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"{workspace} is held by {who:?}, not {hostname}"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
/// A `--stdio --bridge` child connected to an already-listening control socket.
|
||||
fn bridged(sock: &Path, token: &str) -> Client {
|
||||
bridged_for(sock, token, "test-client", None)
|
||||
}
|
||||
|
||||
/// [`bridged`], naming the client machine and, optionally, the workspace this
|
||||
/// connection is opened *for* — the hello field the takeover keys on.
|
||||
fn bridged_for(sock: &Path, token: &str, hostname: &str, workspace: Option<&str>) -> Client {
|
||||
let hello = ControlHello {
|
||||
control_version: tty7_core::daemon::control::CONTROL_VERSION,
|
||||
workspace: workspace.map(str::to_string),
|
||||
client_token: token.to_string(),
|
||||
client_hostname: hostname.to_string(),
|
||||
};
|
||||
bridged_with(sock, hello)
|
||||
}
|
||||
|
||||
fn bridged_with(sock: &Path, hello: ControlHello) -> Client {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_tty7-server"))
|
||||
.args(["--stdio", "--bridge", "--control-sock"])
|
||||
.arg(sock)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("could not start the bridging client");
|
||||
let stdout = child.stdout.take().expect("piped");
|
||||
let stdin = child.stdin.take().expect("piped");
|
||||
let closer: Arc<dyn LinkShutdown> = Arc::new(ServerProcess {
|
||||
child: Mutex::new(Some(child)),
|
||||
});
|
||||
let events: Arc<Mutex<Vec<ControlEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = Arc::clone(&events);
|
||||
let control = ControlClient::connect_with(
|
||||
stdout,
|
||||
stdin,
|
||||
Some(closer),
|
||||
&hello,
|
||||
Box::new(move |e| sink.lock().unwrap_or_else(|e| e.into_inner()).push(e)),
|
||||
)
|
||||
.expect("bridge handshake");
|
||||
let peer_features = control.hello().features.clone();
|
||||
Client {
|
||||
control,
|
||||
events,
|
||||
peer_features,
|
||||
}
|
||||
}
|
||||
+135
-701
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -199,7 +199,7 @@ pub fn open_releases_page() {
|
||||
}
|
||||
|
||||
/// Tiny persisted state for the update checker, stored at `update.json` in the
|
||||
/// config dir (alongside `config.json` / `session.json`). Currently just the
|
||||
/// config dir (alongside `config.json` / `views.json`). Currently just the
|
||||
/// last version we popped the modal for, so we never nag twice for one release.
|
||||
#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
|
||||
struct UpdateState {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! The gpui-facing half of [`WindowState`].
|
||||
//!
|
||||
//! The struct itself, its `window.json` IO, and the "is this geometry sane"
|
||||
//! guard live in `tty7-core` — `session.json` embeds the geometry in each
|
||||
//! [`Workspace`](crate::core::session::Workspace), so it has to parse on a
|
||||
//! machine that never links gpui. What is left here is the only part that
|
||||
//! genuinely needs gpui: turning the four stored `f32`s into a
|
||||
//! [`Bounds<Pixels>`] and back.
|
||||
//! guard live in `tty7-core` — `views.json` embeds the geometry in each
|
||||
//! [`WindowView`](crate::core::session::WindowView), which is defined there.
|
||||
//! What is left here is the only part that genuinely needs gpui: turning the
|
||||
//! four stored `f32`s into a [`Bounds<Pixels>`] and back.
|
||||
|
||||
use gpui::{Bounds, Pixels, point, px};
|
||||
|
||||
|
||||
+18
-9
@@ -79,7 +79,7 @@ fn spawn_config_watcher(cx: &mut App) {
|
||||
let Ok(event) = res else { return };
|
||||
// React to events that touch our `config.json`, or a theme file dropped
|
||||
// into the `themes/` subfolder — both feed the same registry reload below.
|
||||
// Everything else in the dir (`session.json`, `history`, the daemon
|
||||
// Everything else in the dir (`views.json`, `history`, the daemon
|
||||
// socket, and our own `.config.json.tmp.<pid>` / `*.yaml.tmp.<pid>` atomic
|
||||
// scratch files, whose extensions aren't theme extensions) is ignored.
|
||||
let hit = event
|
||||
@@ -317,9 +317,13 @@ fn main() {
|
||||
// Daemon mode: when launched with `--daemon` we run the headless persistent
|
||||
// terminal server and never open a window. This is the backing process the GUI
|
||||
// auto-spawns and reconnects to; it owns all PTYs + child shells and outlives
|
||||
// the GUI. Run to completion (the accept loop blocks until killed) then return.
|
||||
// the GUI. It is the *same* daemon `tty7-server --daemon` runs on a remote
|
||||
// box — panes plus the control dialect — because a local machine and a
|
||||
// remote one are the same thing seen from different distances, and the
|
||||
// workspace tree both serve lives behind control. Run to completion (the
|
||||
// accept loop blocks until killed) then return.
|
||||
if std::env::args().any(|a| a == "--daemon") {
|
||||
if let Err(e) = crate::daemon::server::run() {
|
||||
if let Err(e) = crate::daemon::server::run_daemon() {
|
||||
log::error!("daemon exited with error: {e}");
|
||||
}
|
||||
return;
|
||||
@@ -378,10 +382,9 @@ fn main() {
|
||||
// theme from it. It has to be read here, off the appearance-observer
|
||||
// path — see `ui::theme::SystemAppearance`.
|
||||
crate::ui::theme::refresh_system_appearance(cx);
|
||||
// Read `session.json` (migrating a pre-multi-window file) before any
|
||||
// window is built: windows claim their workspace from this store
|
||||
// rather than each parsing the file themselves. It also dedupes
|
||||
// pane claims here, once, instead of per window.
|
||||
// Read `views.json` before any window is built: windows claim
|
||||
// their workspace from this store rather than each parsing the
|
||||
// file themselves.
|
||||
crate::core::session::WorkspaceStore::init(cx);
|
||||
// The window registry has to exist before the first window opens —
|
||||
// `ui::windows::open` registers into it.
|
||||
@@ -408,20 +411,26 @@ fn main() {
|
||||
})
|
||||
.detach();
|
||||
keymap::init(cx);
|
||||
// Hold a control link to this machine's own daemon, exactly as a
|
||||
// remote machine gets one: the daemon owns the workspace tree and
|
||||
// serves it over control, so the local GUI is a control client
|
||||
// like any other. Supervised on its own forever loop — see
|
||||
// `ui::local_link`.
|
||||
crate::ui::local_link::LocalLink::install(cx);
|
||||
|
||||
// Come up on the *one* workspace the user was last in, at its own
|
||||
// remembered geometry (`ui::windows` owns that logic, since "New
|
||||
// Workspace" and the workspace picker need the identical path).
|
||||
//
|
||||
// Deliberately one window, not one per workspace that was open at
|
||||
// quit: see `Workspaces::workspace_to_restore` for why, and
|
||||
// quit: see `WindowViews::workspace_to_restore` for why, and
|
||||
// `WorkspaceStore::restore_one` for what happens to the others (they
|
||||
// are detached, not forgotten — panes keep running and the switcher
|
||||
// lists them). Quitting with every window closed — or a first run —
|
||||
// opens a single window on a fresh workspace.
|
||||
let any_saved = {
|
||||
let store = crate::core::session::WorkspaceStore::all(cx);
|
||||
!store.workspaces.is_empty()
|
||||
!store.views.is_empty()
|
||||
};
|
||||
let reopen = crate::core::session::WorkspaceStore::restore_one(cx);
|
||||
// With nothing to reopen, what that one window should hold depends on
|
||||
|
||||
@@ -32,12 +32,20 @@
|
||||
//! probably fine and the *link* is what broke — and rendering it as "stopped"
|
||||
//! would tell the user their work is gone every time the network blinks.
|
||||
//!
|
||||
//! **`Unknown` is never shown for this machine.** A local `List` travels a unix
|
||||
//! socket to a daemon whose absence is itself the answer: no daemon, no live
|
||||
//! panes. So a local host with no cached answer reads `Stopped`, which is what
|
||||
//! **A local `List` failing is not `Unknown`.** It travels a unix socket to a
|
||||
//! daemon whose absence is itself the answer: no daemon, no live panes. So a
|
||||
//! local host with no cached *liveness* answer reads `Stopped`, which is what
|
||||
//! this page has always drawn — the async cache changes remote behaviour and
|
||||
//! leaves local pixels alone.
|
||||
//!
|
||||
//! Not knowing which panes to ask about is a different thing, and it is
|
||||
//! `Unknown` on every machine. The ids live in the machine's tree
|
||||
//! ([`crate::ui::machine_mirror`]), so until that first pull lands there is no
|
||||
//! question to put to the daemon — and "no ids yet" must not be read as "no
|
||||
//! sessions", which is a claim about the user's work founded on our own
|
||||
//! ignorance. Locally the pull lands within a frame or two of launch; where
|
||||
//! there is no control link at all, a muted dot is exactly the truth.
|
||||
//!
|
||||
//! # How it is filled
|
||||
//!
|
||||
//! [`sweep`] is called from the render paths that show liveness. It never
|
||||
@@ -58,7 +66,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{App, AppContext as _, BorrowAppContext as _};
|
||||
|
||||
use crate::core::session::{Workspace, WorkspaceId, WorkspaceStore};
|
||||
use crate::core::session::{WindowView, WorkspaceId, WorkspaceStore};
|
||||
use crate::terminal::{PaneRoute, RemoteTerminal};
|
||||
use crate::ui::host_ops::{HostId, InFlight};
|
||||
|
||||
@@ -232,10 +240,17 @@ impl PaneLivenessCache {
|
||||
/// [`PaneLivenessCache::liveness`] for a whole workspace, read-only.
|
||||
///
|
||||
/// The one call the render sites make. It cannot ask the wrong machine: the
|
||||
/// host and the ids both come off the same [`Workspace`].
|
||||
pub fn liveness_of(cx: &App, workspace: &Workspace) -> Liveness {
|
||||
/// host and the ids both come off the same [`WindowView`].
|
||||
pub fn liveness_of(cx: &App, workspace: &WindowView) -> Liveness {
|
||||
let host = workspace.host_id();
|
||||
let ids = workspace.pane_ids();
|
||||
// The ids live in the machine's tree; its mirror is where they are read. A
|
||||
// machine whose tree has not been pulled leaves us with no question to ask,
|
||||
// which is `Unknown` on any machine — reading it as `Stopped` would tell the
|
||||
// user their sessions are gone on the strength of our own ignorance. See the
|
||||
// module docs for why this is *not* the same as a failed local `List`.
|
||||
let Some(ids) = crate::ui::machine_mirror::pane_ids(cx, workspace) else {
|
||||
return Liveness::Unknown;
|
||||
};
|
||||
match cx.try_global::<PaneLivenessCache>() {
|
||||
Some(cache) => cache.liveness(host, &ids),
|
||||
// Before the app has installed the global. Asked of an empty cache
|
||||
@@ -264,12 +279,12 @@ pub fn sweep(cx: &mut App) {
|
||||
// first so the borrow of the store is released before the probes, which
|
||||
// need `cx` mutably.
|
||||
let mut targets: Vec<(HostId, WorkspaceId)> = Vec::new();
|
||||
for w in &WorkspaceStore::all(cx).workspaces {
|
||||
for w in &WorkspaceStore::all(cx).views {
|
||||
let host = w.host_id();
|
||||
if targets.iter().any(|(seen, _)| *seen == host) {
|
||||
continue;
|
||||
}
|
||||
if w.pane_ids().is_empty() {
|
||||
if crate::ui::machine_mirror::pane_ids(cx, w).is_none_or(|ids| ids.is_empty()) {
|
||||
continue;
|
||||
}
|
||||
targets.push((host, w.id));
|
||||
@@ -298,10 +313,10 @@ fn probe_host(cx: &mut App, host: HostId, workspace: WorkspaceId) {
|
||||
//
|
||||
// Recorded as a landed failure rather than returned from: a bare `return`
|
||||
// would leave `needs_probe` true, so the next frame would re-decide this,
|
||||
// and `RemoteConnections::get` reaches its global mutably — which notifies,
|
||||
// and `HostLinks::get` reaches its global mutably — which notifies,
|
||||
// which repaints, which sweeps. Storing the answer puts the decision behind
|
||||
// the same TTL as every other one.
|
||||
if !host.is_local() && crate::ui::remote_connect::RemoteConnections::get(cx, host).is_none() {
|
||||
if !host.is_local() && crate::ui::remote_connect::HostLinks::get(cx, host).is_none() {
|
||||
cx.update_global::<PaneLivenessCache, _>(|cache, _| cache.finish_probe(host, None));
|
||||
return;
|
||||
}
|
||||
|
||||
+29
-8
@@ -98,8 +98,8 @@ impl gpui::EventEmitter<AuthPromptReady> for TerminalView {}
|
||||
///
|
||||
/// The id arrives asynchronously, on the agent's own hooks, long after
|
||||
/// everything that *structurally* changes a window. Nothing else was making the
|
||||
/// window save in between, so whether the id reached `session.json` came down
|
||||
/// to whether the user happened to open a tab, split a pane or move focus
|
||||
/// window save in between, so whether the id reached the persisted layout came
|
||||
/// down to whether the user happened to open a tab, split a pane or move focus
|
||||
/// afterwards. That is what made resume-after-End-Sessions work sometimes and
|
||||
/// not others: the layout on file simply had no agent in it.
|
||||
pub struct AgentSessionChanged;
|
||||
@@ -1599,7 +1599,7 @@ impl TerminalView {
|
||||
/// machine.** The host id comes off the workspace's own `RemoteTarget`,
|
||||
/// through the same `connection_key` the connection was opened under — so
|
||||
/// the id resolves to the very host object
|
||||
/// [`RemoteConnections::insert`](crate::ui::remote_connect::RemoteConnections::insert)
|
||||
/// [`HostLinks::insert`](crate::ui::remote_connect::HostLinks::insert)
|
||||
/// registered, with no second source of truth to drift from it. Setting the
|
||||
/// route and setting the host is one operation because a pane that ran its
|
||||
/// shell on one machine and its `git` on another would be worse than
|
||||
@@ -8017,7 +8017,7 @@ mod tests {
|
||||
/// (which needs a window, a daemon and a pane): the derivation under test is
|
||||
/// the target → `HostId` one, and pinning it here is what catches a future
|
||||
/// `set_workspace` that forgets the host half. The ids must agree with what
|
||||
/// `RemoteConnections::insert` registered — same `connection_key`, checked
|
||||
/// `HostLinks::insert` registered — same `connection_key`, checked
|
||||
/// by `connection_keys_match_the_contract_table` in `tty7-core`.
|
||||
#[test]
|
||||
fn a_panes_host_is_its_workspaces_machine() {
|
||||
@@ -8075,6 +8075,27 @@ pub(crate) fn quiet_test_pane(
|
||||
(view, daemon_side)
|
||||
}
|
||||
|
||||
/// [`quiet_test_pane`], marked as a native-SSH pane — the shape a remote
|
||||
/// window's local SSH split has. `ssh_spec` is otherwise set only by the real
|
||||
/// spawn path, which needs an actual SSH handshake.
|
||||
#[cfg(all(test, unix))]
|
||||
pub(crate) fn quiet_test_ssh_pane(
|
||||
pane_id: u64,
|
||||
window: &mut Window,
|
||||
cx: &mut gpui::App,
|
||||
) -> (gpui::Entity<TerminalView>, std::os::unix::net::UnixStream) {
|
||||
let (view, stream) = quiet_test_pane(pane_id, window, cx);
|
||||
view.update(cx, |view, _| {
|
||||
view.ssh_spec = Some(Box::new(
|
||||
serde_json::from_str(
|
||||
r#"{"host":"build-box","port":22,"user":"me","auth_mode":"auto"}"#,
|
||||
)
|
||||
.expect("a minimal NativeSshSpec decodes"),
|
||||
));
|
||||
});
|
||||
(view, stream)
|
||||
}
|
||||
|
||||
/// gpui-harness tests: a real (headless) App + Window around a `TerminalView`
|
||||
/// wired to a socketpair, so `handle_event` and the event pump run exactly as
|
||||
/// in production. The test plays the daemon on the other end of the socket —
|
||||
@@ -9658,19 +9679,19 @@ mod gpui_tests {
|
||||
cx: &mut Context<TerminalView>,
|
||||
) -> crate::core::session::WorkspaceId {
|
||||
use crate::core::session::{
|
||||
RemoteRef, RemoteTarget, WorkspaceId, WorkspaceStore, Workspaces,
|
||||
RemoteRef, RemoteTarget, WindowViews, WorkspaceId, WorkspaceStore,
|
||||
};
|
||||
use crate::terminal::PaneWorkspace;
|
||||
let host = RemoteRef::new(
|
||||
RemoteTarget::direct("me", "build-box", 22),
|
||||
WorkspaceId::new(),
|
||||
);
|
||||
let entry = crate::core::session::Workspace::on_remote(host.clone());
|
||||
let entry = crate::core::session::WindowView::on_remote(host.clone());
|
||||
let id = entry.id;
|
||||
WorkspaceStore::install_for_test(
|
||||
cx,
|
||||
Workspaces {
|
||||
workspaces: vec![entry],
|
||||
WindowViews {
|
||||
views: vec![entry],
|
||||
active: None,
|
||||
},
|
||||
);
|
||||
|
||||
+301
-90
@@ -449,6 +449,14 @@ pub struct Tab {
|
||||
/// clicking a file in the tree behind it brings the editor back — the same
|
||||
/// "click it, it comes forward" rule as window stacking.
|
||||
pub(crate) overlay_top: OverlayTop,
|
||||
/// This tab's identity in the daemon's machine tree — the id every
|
||||
/// semantic operation about it carries. Minted here (the daemon keeps a
|
||||
/// client-minted id, see `ControlRequest::TabCreate`), so the tab can be
|
||||
/// addressed before its create has round-tripped. A `Cell` because the
|
||||
/// sync layer re-points it at an existing daemon tab when it recognizes
|
||||
/// one by its panes (`tree_sync::adopt_tab_ids`), and that pass runs with
|
||||
/// the same shared borrow every save runs under.
|
||||
pub(crate) tree_id: std::cell::Cell<tty7_core::core::machine::TabId>,
|
||||
}
|
||||
|
||||
/// Stacking order for the two overlays that cover the whole column. See
|
||||
@@ -470,6 +478,25 @@ impl Tab {
|
||||
code: None,
|
||||
overlay_top: OverlayTop::default(),
|
||||
sidebar_group: std::cell::RefCell::new(None),
|
||||
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A tab mirroring one the daemon's tree already holds — labels and
|
||||
/// identity from the tree, the pane views from `pane` (built by the delta
|
||||
/// application, which attaches or reuses them).
|
||||
pub(crate) fn from_tree(tree: &tty7_core::core::machine::Tab, pane: Pane) -> Self {
|
||||
Self {
|
||||
pane,
|
||||
name: tree.name.clone(),
|
||||
last_focused: None,
|
||||
diff_overlay: None,
|
||||
code: None,
|
||||
overlay_top: OverlayTop::default(),
|
||||
sidebar_group: std::cell::RefCell::new(
|
||||
tree.sidebar_group.clone().map(std::path::PathBuf::from),
|
||||
),
|
||||
tree_id: std::cell::Cell::new(tree.id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,7 +735,7 @@ pub struct Tty7App {
|
||||
pub(crate) worktree_prompt: Option<crate::ui::worktree_prompt::WorktreePrompt>,
|
||||
/// When `Some`, the active tab renders only this one leaf full-window
|
||||
/// (Cmd+Shift+Enter maximize). Cleared on any structural / navigation change.
|
||||
maximized: Option<Entity<TerminalView>>,
|
||||
pub(crate) maximized: Option<Entity<TerminalView>>,
|
||||
/// Whether the tab chips currently show their ⌘1…⌘9 switch badges
|
||||
/// (shown while bare ⌘/Ctrl is held; see `hints::on_modifiers_changed`).
|
||||
pub(crate) mod_hint_badges: bool,
|
||||
@@ -893,30 +920,59 @@ impl Tty7App {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
// Claiming marks the workspace open and hands back its saved tabs, so
|
||||
// the store (not this window) stays the single writer of session.json.
|
||||
// Claiming marks the workspace open; the store stays the single
|
||||
// writer of the view file.
|
||||
let restore = cx.global::<Config>().restore_session;
|
||||
let known = id.is_some_and(|id| WorkspaceStore::all(cx).get(id).is_some());
|
||||
let (workspace, saved) = WorkspaceStore::claim(cx, id);
|
||||
// A workspace that was already on file restores its tab/split layout and
|
||||
// each pane's cwd, unless the user turned restore off — then it starts
|
||||
// fresh. A *brand-new* one has no tabs to restore, so what it comes up
|
||||
// with is the caller's call: `None` here takes the first-run path in
|
||||
// `with_session`, spawning a single default terminal, which is what
|
||||
// `New Workspace` and a first run both want. Handing an empty session
|
||||
// through instead lands on the home page, for the launch that exists to
|
||||
// show the workspace picker.
|
||||
let workspace = WorkspaceStore::claim(cx, id);
|
||||
// A workspace's layout lives in its machine's tree, so a restore
|
||||
// *asks* rather than reads: the window opens empty and
|
||||
// `hydrate_window_from_tree` rebuilds it the moment the pull answers —
|
||||
// against the local daemon that is milliseconds, so the empty state is
|
||||
// effectively one frame; against a remote machine it is however long
|
||||
// the link takes, which is the shape remote windows always had. A
|
||||
// remote machine still unreachable when the hydration gives up is
|
||||
// re-hydrated by the supervisor's reconnect.
|
||||
let is_remote = WorkspaceStore::all(cx)
|
||||
.get(workspace)
|
||||
.is_some_and(|w| w.is_remote());
|
||||
// A remote workspace hydrates even with restore off: its panes are
|
||||
// running sessions on another machine, not a saved layout.
|
||||
let hydrate = known && (restore || is_remote);
|
||||
// What the window opens holding is the caller's call for a *brand-new*
|
||||
// workspace: `None` takes the first-run path in `with_session`,
|
||||
// spawning a single default terminal — what `New Workspace` and a
|
||||
// first run both want — while an empty session lands on the home page,
|
||||
// for the launch that exists to show the workspace picker. A known
|
||||
// workspace opens empty (the hydration fills it), or on a fresh shell
|
||||
// when the user turned restore off.
|
||||
let session = match (known, fresh) {
|
||||
(true, _) => restore.then_some(saved),
|
||||
(true, _) if hydrate => Some(Session::default()),
|
||||
(true, _) => None,
|
||||
(false, crate::ui::windows::FreshStart::Shell) => None,
|
||||
(false, crate::ui::windows::FreshStart::HomePage) => Some(Session::default()),
|
||||
};
|
||||
let app = Self::with_session(Some(workspace), session, window, cx);
|
||||
// Persist right away. The leaves just spawned (or reattached) now carry
|
||||
// daemon pane ids, and nothing else writes them until the next
|
||||
// *structural* change — so a crash before the user happens to open a
|
||||
// tab would strand every one of those panes in the daemon.
|
||||
app.save_session(cx);
|
||||
if hydrate {
|
||||
// No immediate save: the window is deliberately empty, and racing
|
||||
// the pull with a diff that reads as "close everything" is exactly
|
||||
// what the informed gate exists to prevent.
|
||||
crate::ui::tree_sync::hydrate_window_from_tree(cx, workspace);
|
||||
} else {
|
||||
// A local window that skipped hydration shows what the user chose
|
||||
// (a fresh shell, restore off): its state is the intended layout,
|
||||
// and its sync may speak for the whole tree. A remote window that
|
||||
// lands here has *not* seen its machine's tree yet, so it stays
|
||||
// additive until a hydration informs it.
|
||||
if !is_remote {
|
||||
crate::ui::tree_sync::mark_window_informed(cx, workspace);
|
||||
}
|
||||
// Persist right away. The leaves just spawned (or reattached) now
|
||||
// carry daemon pane ids, and nothing else writes them until the
|
||||
// next *structural* change — so a crash before the user happens to
|
||||
// open a tab would strand every one of those panes in the daemon.
|
||||
app.save_session(cx);
|
||||
}
|
||||
// If startup reused a daemon that speaks a different wire protocol
|
||||
// (an app upgrade while the old service kept running), the sessions
|
||||
// just restored above are living on that old dialect. Surface the
|
||||
@@ -1386,12 +1442,12 @@ impl Tty7App {
|
||||
app
|
||||
}
|
||||
|
||||
/// Snapshot the current tabs/active index into a `Session` and persist it.
|
||||
/// Called after every structural change; the write is a small synchronous
|
||||
/// JSON dump and any error is swallowed inside `Session::save`.
|
||||
/// Push this window's structure to its machine's tree (and its geometry to
|
||||
/// the view file). Called after every structural change — the name
|
||||
/// predates the tree migration, and it remains the single funnel.
|
||||
pub(crate) fn save_session(&self, cx: &mut App) {
|
||||
// Tripwire for the write this record must never take: a pane created
|
||||
// for one workspace being persisted under another. Each view remembers
|
||||
// Tripwire for the write this sync must never make: a pane created
|
||||
// for one workspace being recorded under another. Each view remembers
|
||||
// the workspace whose window created it; if that and the id this save
|
||||
// records under have come apart, the window's tabs and its identity
|
||||
// are describing two different workspaces — the exact corruption that
|
||||
@@ -1413,34 +1469,16 @@ impl Tty7App {
|
||||
);
|
||||
}
|
||||
}
|
||||
let tabs: Vec<SessionTab> = self
|
||||
.tabs
|
||||
.iter()
|
||||
.map(|tab| tab_to_session(tab, cx))
|
||||
.collect();
|
||||
// Zero tabs is a real state (the home page) and is persisted as such, so
|
||||
// the next launch comes back to it instead of a fresh shell.
|
||||
let active = if tabs.is_empty() {
|
||||
0
|
||||
} else {
|
||||
self.active.min(tabs.len() - 1)
|
||||
};
|
||||
let session = Session { active, tabs };
|
||||
// The store merges this into the other windows' workspaces and owns the
|
||||
// write; the geometry rides along so reopening lands where we are now.
|
||||
WorkspaceStore::record(
|
||||
// The layout goes nowhere near the view file: the machine that owns it
|
||||
// hears about the change as the semantic operations it amounts to,
|
||||
// local and remote alike. What this client persists is only the
|
||||
// geometry, ridden on the same funnel so reopening lands where we are.
|
||||
WorkspaceStore::record_geometry(
|
||||
cx,
|
||||
self.workspace,
|
||||
session,
|
||||
Some(WindowState::from_bounds(self.window_bounds)),
|
||||
WindowState::from_bounds(self.window_bounds),
|
||||
);
|
||||
// …and for a remote workspace the machine that owns the layout has to
|
||||
// hear about it, or `session.json` is the only place it exists and any
|
||||
// other client (or a fresh install) opens the workspace empty. No-ops
|
||||
// for a local workspace and for a machine we are not connected to —
|
||||
// the latter is also what keeps a window that failed to restore from
|
||||
// pushing its emptiness over a good record.
|
||||
self.push_remote_layout(self.workspace, cx);
|
||||
crate::ui::tree_sync::sync_window(self, cx);
|
||||
}
|
||||
|
||||
/// This window is going away: capture its final state (a plain `cd` may
|
||||
@@ -1457,20 +1495,32 @@ impl Tty7App {
|
||||
// every `New Workspace` the user closes without using would leave one.
|
||||
//
|
||||
// Unless the emptiness is *this client's* ignorance rather than the
|
||||
// machine's answer. `claimable_session` deliberately opens a remote
|
||||
// workspace empty when its machine cannot be reached, so a window
|
||||
// opened while the box was asleep and then closed — there was nothing
|
||||
// in it to work on — would take the entry with it: its `RemoteRef`, its
|
||||
// cached layout and its geometry, while its panes are still running
|
||||
// machine's answer. Every window opens empty and waits for its tree
|
||||
// pull, so a window opened while the box was asleep and then closed —
|
||||
// there was nothing in it to work on — would take the entry with it:
|
||||
// its `RemoteRef` and its geometry, while its panes are still running
|
||||
// over there. Nothing would reconnect it and nothing would offer it
|
||||
// again; the only way back is re-adding the machine by hand.
|
||||
let answered = WorkspaceStore::machine_is_connected(cx, self.workspace);
|
||||
if self.tabs.is_empty() && answered {
|
||||
if self.tabs.is_empty()
|
||||
&& answered
|
||||
&& crate::ui::tree_sync::window_is_informed(cx, self.workspace)
|
||||
{
|
||||
// Same as the picker swap: an empty workspace being dropped takes
|
||||
// its (empty) tree on the machine with it. Only an informed window
|
||||
// may say so — one still waiting on its hydration is empty because
|
||||
// the pull has not answered, not because the workspace is.
|
||||
crate::ui::tree_sync::fire_workspace_op(cx, self.workspace, |ws| {
|
||||
tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws }
|
||||
});
|
||||
WorkspaceStore::remove(cx, self.workspace);
|
||||
} else {
|
||||
WorkspaceStore::close_window(cx, self.workspace);
|
||||
}
|
||||
crate::ui::windows::WindowRegistry::unregister(cx, self.workspace);
|
||||
// The window's tree-sync bookkeeping goes with the window; the
|
||||
// machine's tree itself keeps the workspace, which is the detach.
|
||||
crate::ui::tree_sync::forget(cx, self.workspace);
|
||||
// The workspace just moved from "on screen" to "detached" — the Window
|
||||
// menu is the only place that says so.
|
||||
crate::ui::windows::refresh_menu(cx);
|
||||
@@ -1602,16 +1652,35 @@ impl Tty7App {
|
||||
if previous == id {
|
||||
return;
|
||||
}
|
||||
if self.tabs.is_empty() {
|
||||
// Only an *informed* empty window proves the workspace is blank: one
|
||||
// still waiting on its hydration is empty because the pull has not
|
||||
// answered, and dropping the workspace then would delete a populated
|
||||
// tree on the strength of our own ignorance.
|
||||
if self.tabs.is_empty() && crate::ui::tree_sync::window_is_informed(cx, previous) {
|
||||
// Dropping the blank workspace here, so the machine's tree drops
|
||||
// its (equally blank) copy — otherwise every visit to the picker
|
||||
// would leave an empty workspace behind on the daemon.
|
||||
crate::ui::tree_sync::fire_workspace_op(cx, previous, |ws| {
|
||||
tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws }
|
||||
});
|
||||
WorkspaceStore::remove(cx, previous);
|
||||
} else if self.tabs.is_empty() {
|
||||
WorkspaceStore::close_window(cx, previous);
|
||||
} else {
|
||||
self.save_session(cx);
|
||||
WorkspaceStore::close_window(cx, previous);
|
||||
}
|
||||
crate::ui::tree_sync::forget(cx, previous);
|
||||
|
||||
let (claimed, session) = WorkspaceStore::claim(cx, Some(id));
|
||||
let claimed = WorkspaceStore::claim(cx, Some(id));
|
||||
crate::ui::windows::WindowRegistry::rebind(cx, previous, claimed);
|
||||
self.adopt_workspace(claimed, session, window, cx);
|
||||
// The machine's tree is the layout's only home now, so an explicit
|
||||
// pick from the switcher always hydrates — restore-off governs what
|
||||
// *launch* comes back to, not what a deliberate open shows. The window
|
||||
// swaps to empty and the pull rebuilds it, for the local daemon within
|
||||
// milliseconds.
|
||||
self.adopt_workspace(claimed, Session::default(), window, cx);
|
||||
crate::ui::tree_sync::hydrate_window_from_tree(cx, claimed);
|
||||
}
|
||||
|
||||
/// Take over an *already claimed* workspace: rebuild this window's tabs
|
||||
@@ -1695,6 +1764,7 @@ impl Tty7App {
|
||||
// Keep the group it had when closed — the row reappears where
|
||||
// it lived instead of flashing through Scratch.
|
||||
sidebar_group: std::cell::RefCell::new(st.sidebar_group),
|
||||
tree_id: std::cell::Cell::new(tty7_core::core::machine::TabId::new()),
|
||||
},
|
||||
);
|
||||
self.active = insert_at;
|
||||
@@ -1935,27 +2005,17 @@ impl Tty7App {
|
||||
// layout returns exactly as it was.
|
||||
let _ = this.update_in(cx, |this, window, cx| {
|
||||
match &restarted {
|
||||
// Rebuild from the machine's tree, which survived the
|
||||
// restart on disk: the fresh daemon force-cleared every
|
||||
// pane's live flag, so the resync revives each leaf as a
|
||||
// fresh shell in its recorded cwd (agents resumed) —
|
||||
// exactly the semantics the old saved-session rebuild
|
||||
// hand-rolled. The pull waits out the local link coming
|
||||
// back up to the fresh daemon.
|
||||
Ok(()) => {
|
||||
let font_size = this.font_size;
|
||||
// This window's own workspace only — the other windows
|
||||
// rebuild themselves from theirs.
|
||||
let saved = WorkspaceStore::all(cx)
|
||||
.get(this.workspace)
|
||||
.map(|w| w.session.clone());
|
||||
let pane_ws = this.window_workspace(cx);
|
||||
let (tabs, active) = tabs_from_session(
|
||||
pane_ws.as_ref(),
|
||||
this.workspace,
|
||||
saved,
|
||||
font_size,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
this.tabs = tabs;
|
||||
this.active = active;
|
||||
crate::ui::tree_sync::resync_window_from_tree(cx, this.workspace);
|
||||
}
|
||||
// The fresh daemon never came up; rebuilding would panic in
|
||||
// `new_terminal`'s connect `.expect`. Stay on the home page and
|
||||
// The fresh daemon never came up. Stay on the home page and
|
||||
// leave a breadcrumb rather than crash — the user can retry.
|
||||
Err(e) => {
|
||||
log::error!("restart background service failed, staying on home page: {e}");
|
||||
@@ -3057,8 +3117,8 @@ impl Tty7App {
|
||||
pub(crate) fn sync_window_title(&self, window: &mut Window, cx: &App) {
|
||||
let title = WorkspaceStore::all(cx)
|
||||
.get(self.workspace)
|
||||
.filter(|w| !w.session.tabs.is_empty())
|
||||
.map(|w| w.display_name())
|
||||
.filter(|w| crate::ui::machine_mirror::pane_count(cx, w).unwrap_or(0) > 0)
|
||||
.and_then(|w| crate::ui::machine_mirror::display_name(cx, w))
|
||||
.unwrap_or_else(|| "tty7".to_string());
|
||||
if *self.window_title.borrow() == title {
|
||||
return;
|
||||
@@ -4352,10 +4412,8 @@ impl Tty7App {
|
||||
/// Turn the title-bar workspace chip into a text field, seeded with the
|
||||
/// current name. Committing on Enter or blur mirrors the tab rename.
|
||||
pub(crate) fn start_workspace_rename(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let current = WorkspaceStore::all(cx)
|
||||
.get(self.workspace)
|
||||
.map(|w| w.display_name())
|
||||
.unwrap_or_default();
|
||||
let current =
|
||||
crate::ui::machine_mirror::display_name_for(cx, self.workspace).unwrap_or_default();
|
||||
let input = cx.new(|cx| InputState::new(window, cx).default_value(current));
|
||||
input.update(cx, |state, cx| state.focus(window, cx));
|
||||
let subs = vec![cx.subscribe_in(
|
||||
@@ -4381,7 +4439,7 @@ impl Tty7App {
|
||||
};
|
||||
let value = rename.input.read(cx).value().trim().to_string();
|
||||
let id = self.workspace;
|
||||
WorkspaceStore::rename(cx, id, (!value.is_empty()).then_some(value));
|
||||
crate::ui::tree_sync::rename_workspace(cx, id, (!value.is_empty()).then_some(value));
|
||||
crate::ui::windows::refresh_menu(cx);
|
||||
self.sync_window_title(window, cx);
|
||||
self.focus_active(window, cx);
|
||||
@@ -5836,7 +5894,7 @@ impl Tty7App {
|
||||
if !host.is_connected() {
|
||||
return None;
|
||||
}
|
||||
let home = crate::ui::remote_connect::RemoteConnections::home(cx, host_id)?;
|
||||
let home = crate::ui::remote_connect::HostLinks::home(cx, host_id)?;
|
||||
Some((host, Some(home)))
|
||||
}
|
||||
|
||||
@@ -7123,6 +7181,10 @@ fn tab_to_session(tab: &Tab, cx: &App) -> SessionTab {
|
||||
name: tab.name.clone(),
|
||||
pane: pane_to_session(&tab.pane, cx),
|
||||
sidebar_group: tab.sidebar_group.borrow().clone(),
|
||||
// Deliberately not the live tab's tree id. This snapshot outlives the
|
||||
// daemon tab it mirrors (the closed-tab stack, the session file), and
|
||||
// rebuilding from it is a *new* tab everywhere it matters.
|
||||
tree_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7260,7 +7322,7 @@ pub(crate) fn alive_panes_on(
|
||||
/// gate on session restore.
|
||||
///
|
||||
/// The failure this closes: two workspace records claiming one pane id (a
|
||||
/// corrupted `session.json`), or a stale id landing on an unrelated pane after
|
||||
/// corrupted layout store), or a stale id landing on an unrelated pane after
|
||||
/// the numbers were reused. Before the daemon knew owners, both cases attached
|
||||
/// — one workspace's window silently picked up another's shell, which is how
|
||||
/// `work`'s seven tabs once ended up duplicated into `personal`. A pane with no
|
||||
@@ -7326,6 +7388,13 @@ fn tabs_from_session(
|
||||
// renders grouped on the first frame; the first landed probe
|
||||
// corrects it if the tab's repo changed while we were gone.
|
||||
sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()),
|
||||
// A session lowered from the machine's tree names its daemon tabs;
|
||||
// keeping those ids is what stops the first save from closing and
|
||||
// recreating every one of them.
|
||||
tree_id: std::cell::Cell::new(
|
||||
st.tree_id
|
||||
.unwrap_or_else(tty7_core::core::machine::TabId::new),
|
||||
),
|
||||
});
|
||||
}
|
||||
// Clamp the saved active index into the rebuilt range (which can be empty
|
||||
@@ -7500,7 +7569,7 @@ fn session_to_pane(
|
||||
/// stamped on the view (so `save_session` can shout if a window's tabs and its
|
||||
/// identity ever come apart). `None` only for callers that genuinely have no
|
||||
/// workspace (tests).
|
||||
fn new_terminal(
|
||||
pub(crate) fn new_terminal(
|
||||
workspace: Option<crate::terminal::PaneWorkspace>,
|
||||
owner: Option<WorkspaceId>,
|
||||
font_size: f32,
|
||||
@@ -8471,6 +8540,148 @@ pub(crate) mod test_window {
|
||||
}
|
||||
}
|
||||
|
||||
/// A native-SSH split inside a *remote* workspace's window runs in this
|
||||
/// client's daemon and is deliberately absent from the remote machine's tree —
|
||||
/// so a tree-driven tab rebuild has no leaf for it, and has to keep its view
|
||||
/// anyway or a running local session is orphaned with nothing on screen.
|
||||
#[cfg(all(test, unix))]
|
||||
mod ssh_rebuild_gpui_tests {
|
||||
use super::test_window::harness_with_pane;
|
||||
use crate::core::session::{
|
||||
RemoteRef, RemoteTarget, WindowView, WindowViews, WorkspaceId, WorkspaceStore,
|
||||
};
|
||||
use crate::ui::pane::{Pane, PaneSlot};
|
||||
use gpui::TestAppContext;
|
||||
use tty7_core::core::machine::{LayoutDelta, PaneNode, Tab as TreeTab};
|
||||
|
||||
#[gpui::test]
|
||||
fn a_tree_rebuild_keeps_the_native_ssh_split_a_remote_tab_holds(cx: &mut TestAppContext) {
|
||||
// A window with one tab holding remote pane 1 (as far as the window is
|
||||
// concerned; the socketpair plays the daemon).
|
||||
let (app, mut vcx, _remote_pane_stream) = harness_with_pane(cx);
|
||||
|
||||
// Bind the window to a remote workspace and split a native-SSH pane
|
||||
// into the tab — the state a remote window with a local SSH split has.
|
||||
let remote = WindowView::on_remote(RemoteRef::new(
|
||||
RemoteTarget::Alias {
|
||||
alias: "build-box".into(),
|
||||
},
|
||||
WorkspaceId::new(),
|
||||
));
|
||||
let remote_id = remote.id;
|
||||
let _ssh_stream = app.update_in(&mut vcx, |app, window, cx| {
|
||||
WorkspaceStore::install_for_test(
|
||||
cx,
|
||||
WindowViews {
|
||||
views: vec![remote],
|
||||
active: None,
|
||||
},
|
||||
);
|
||||
app.workspace = remote_id;
|
||||
let (ssh_view, stream) = crate::terminal::view::quiet_test_ssh_pane(2, window, cx);
|
||||
let existing = std::mem::replace(&mut app.tabs[0].pane, Pane::Empty);
|
||||
app.tabs[0].pane = Pane::split_node(
|
||||
gpui::Axis::Horizontal,
|
||||
0.5,
|
||||
existing,
|
||||
Pane::leaf(PaneSlot::Ready(ssh_view)),
|
||||
);
|
||||
stream
|
||||
});
|
||||
|
||||
// Another client of the remote machine restructured the tab. The
|
||||
// delta's tree names only the remote pane — the SSH leaf was never in
|
||||
// that tree to be named.
|
||||
let applied = app.update_in(&mut vcx, |app, window, cx| {
|
||||
let tab = TreeTab {
|
||||
id: app.tabs[0].tree_id.get(),
|
||||
name: None,
|
||||
sidebar_group: None,
|
||||
root: PaneNode::Leaf { pane: 1 },
|
||||
};
|
||||
app.apply_layout_delta(
|
||||
&LayoutDelta::TabRestructured { tab, pane: None },
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
assert!(
|
||||
applied,
|
||||
"the delta must apply without falling back to a resync"
|
||||
);
|
||||
|
||||
app.update_in(&mut vcx, |app, _, cx| {
|
||||
let leaves = app.tabs[0].pane.leaves();
|
||||
assert_eq!(leaves.len(), 2, "the ssh split must survive the rebuild");
|
||||
assert!(
|
||||
leaves.iter().any(|slot| match slot {
|
||||
PaneSlot::Ready(view) => view.read(cx).ssh_spec().is_some(),
|
||||
_ => false,
|
||||
}),
|
||||
"one leaf is still the native-SSH pane"
|
||||
);
|
||||
assert!(
|
||||
leaves.iter().any(|slot| match slot {
|
||||
PaneSlot::Ready(view) => {
|
||||
let view = view.read(cx);
|
||||
view.ssh_spec().is_none() && view.pane_id == 1
|
||||
}
|
||||
_ => false,
|
||||
}),
|
||||
"the remote pane's existing view is reused, not re-attached"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A remote window's tab that is native-SSH through and through is
|
||||
/// unrepresentable in the machine's tree **forever** — so it must be
|
||||
/// invisible to the diff, not *held*. Held means "spawns are landing,
|
||||
/// wait"; a tab that can never land would make 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 exists.
|
||||
#[gpui::test]
|
||||
fn a_pure_native_ssh_tab_is_invisible_to_the_tree_not_held(cx: &mut TestAppContext) {
|
||||
let (app, mut vcx, _remote_pane_stream) = harness_with_pane(cx);
|
||||
|
||||
let remote = WindowView::on_remote(RemoteRef::new(
|
||||
RemoteTarget::Alias {
|
||||
alias: "build-box".into(),
|
||||
},
|
||||
WorkspaceId::new(),
|
||||
));
|
||||
let remote_id = remote.id;
|
||||
let _ssh_stream = app.update_in(&mut vcx, |app, window, cx| {
|
||||
WorkspaceStore::install_for_test(
|
||||
cx,
|
||||
WindowViews {
|
||||
views: vec![remote],
|
||||
active: None,
|
||||
},
|
||||
);
|
||||
app.workspace = remote_id;
|
||||
// A second tab holding only a native-SSH pane.
|
||||
let (ssh_view, stream) = crate::terminal::view::quiet_test_ssh_pane(2, window, cx);
|
||||
app.tabs
|
||||
.push(super::Tab::new(Pane::leaf(PaneSlot::Ready(ssh_view))));
|
||||
stream
|
||||
});
|
||||
|
||||
let (desired, _active, held) = app.update_in(&mut vcx, |app, _, cx| {
|
||||
crate::ui::tree_sync::desired_tabs(app, cx)
|
||||
});
|
||||
assert_eq!(
|
||||
desired.len(),
|
||||
1,
|
||||
"only the remote-backed tab can be named in the machine's tree"
|
||||
);
|
||||
assert!(
|
||||
held.is_empty(),
|
||||
"the pure-SSH tab is permanently invisible, not held — holding it \
|
||||
would freeze ordering and active-tab sync for the whole window"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod keybinding_gpui_tests {
|
||||
use super::test_window::harness;
|
||||
@@ -8592,7 +8803,7 @@ mod keybinding_gpui_tests {
|
||||
mod shell_menu_gpui_tests {
|
||||
use crate::core::config::Config;
|
||||
use crate::core::session::{
|
||||
RemoteRef, RemoteTarget, Session, Workspace, WorkspaceId, WorkspaceStore, Workspaces,
|
||||
RemoteRef, RemoteTarget, Session, WindowView, WindowViews, WorkspaceId, WorkspaceStore,
|
||||
};
|
||||
use crate::ui::app::Tty7App;
|
||||
use gpui::{AppContext, Entity, TestAppContext, VisualTestContext};
|
||||
@@ -8683,7 +8894,7 @@ mod shell_menu_gpui_tests {
|
||||
);
|
||||
|
||||
// A workspace on a machine nothing in this process has connected to.
|
||||
let remote = Workspace::on_remote(RemoteRef::new(
|
||||
let remote = WindowView::on_remote(RemoteRef::new(
|
||||
RemoteTarget::Alias {
|
||||
alias: "build-box".into(),
|
||||
},
|
||||
@@ -8693,8 +8904,8 @@ mod shell_menu_gpui_tests {
|
||||
app.update_in(&mut vcx, |app, window, cx| {
|
||||
WorkspaceStore::install_for_test(
|
||||
cx,
|
||||
Workspaces {
|
||||
workspaces: vec![remote],
|
||||
WindowViews {
|
||||
views: vec![remote],
|
||||
active: None,
|
||||
},
|
||||
);
|
||||
|
||||
+1
-1
@@ -150,7 +150,7 @@ mod gpui_tests {
|
||||
});
|
||||
// Inject the zero-tab session (the persisted home-page state) so the
|
||||
// app builds without spawning a terminal — and without reading the
|
||||
// on-disk `session.json`.
|
||||
// on-disk view store.
|
||||
let window = cx.add_window(|window, cx| {
|
||||
Tty7App::with_session(None, Some(Session::default()), window, cx)
|
||||
});
|
||||
|
||||
@@ -318,6 +318,7 @@ mod tests {
|
||||
fn closed_tab_label_prefers_the_user_set_name() {
|
||||
let tab = SessionTab {
|
||||
name: Some("build".into()),
|
||||
tree_id: None,
|
||||
sidebar_group: None,
|
||||
pane: leaf(Some("/work/getty")),
|
||||
};
|
||||
@@ -328,6 +329,7 @@ mod tests {
|
||||
fn closed_tab_label_falls_back_to_the_first_leaf_cwd_dir_name() {
|
||||
let tab = SessionTab {
|
||||
name: None,
|
||||
tree_id: None,
|
||||
sidebar_group: None,
|
||||
pane: leaf(Some("/work/getty")),
|
||||
};
|
||||
@@ -336,6 +338,7 @@ mod tests {
|
||||
// Whitespace-only names don't count as names.
|
||||
let tab = SessionTab {
|
||||
name: Some(" ".into()),
|
||||
tree_id: None,
|
||||
sidebar_group: None,
|
||||
pane: leaf(Some("/work/getty")),
|
||||
};
|
||||
@@ -346,6 +349,7 @@ mod tests {
|
||||
fn closed_tab_label_searches_splits_for_the_first_cwd() {
|
||||
let tab = SessionTab {
|
||||
name: None,
|
||||
tree_id: None,
|
||||
sidebar_group: None,
|
||||
pane: SessionPane::Split {
|
||||
axis: crate::core::session::SessionAxis::Horizontal,
|
||||
@@ -362,12 +366,14 @@ mod tests {
|
||||
// No name, no cwd — and "/" has no file name either.
|
||||
let unnamed = SessionTab {
|
||||
name: None,
|
||||
tree_id: None,
|
||||
sidebar_group: None,
|
||||
pane: leaf(None),
|
||||
};
|
||||
assert_eq!(closed_tab_label(&unnamed), None);
|
||||
let root = SessionTab {
|
||||
name: None,
|
||||
tree_id: None,
|
||||
sidebar_group: None,
|
||||
pane: leaf(Some("/")),
|
||||
};
|
||||
@@ -378,6 +384,7 @@ mod tests {
|
||||
fn closed_tab_label_clamps_runaway_names() {
|
||||
let tab = SessionTab {
|
||||
name: Some("a".repeat(40)),
|
||||
tree_id: None,
|
||||
sidebar_group: None,
|
||||
pane: leaf(None),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
//! The GUI's control link to **this machine's own daemon**.
|
||||
//!
|
||||
//! Local and remote machines are the same thing seen from different distances:
|
||||
//! one machine, one daemon, one workspace tree, one control link. The remote
|
||||
//! machines' links live in [`crate::ui::remote_connect::HostLinks`]; this
|
||||
//! module is the local machine's — the link over which the GUI receives the
|
||||
//! local daemon's pushes (`ControlEvent::Layout` deltas, `Preempted`) and
|
||||
//! sends its semantic tree operations.
|
||||
//!
|
||||
//! # Not a `HostLinks` entry
|
||||
//!
|
||||
//! `HostLinks` doubles as the [`crate::ui::host_registry::HostRegistry`]
|
||||
//! feeder: inserting there would register a *wire-backed* `Host` for this
|
||||
//! machine, while the local file tree and git must keep going through the
|
||||
//! in-process [`LocalHost`](tty7_core::host::local::LocalHost) — a socket
|
||||
//! round trip per `stat` on the machine you are sitting at would be absurd. It
|
||||
//! also keeps the `HostId::LOCAL`-never-holds-a-control-connection invariant
|
||||
//! untouched: this link lives in its own global, not in any host table.
|
||||
//!
|
||||
//! # Not routed
|
||||
//!
|
||||
//! A remote control connection dials the local daemon's *pane* socket and asks
|
||||
//! it to route (the GUI never speaks SSH). This machine needs no routing — the
|
||||
//! daemon's control endpoint is right here, so the link is a plain connect plus
|
||||
//! a `ControlHello`.
|
||||
//!
|
||||
//! # Its own pump
|
||||
//!
|
||||
//! The remote supervisor's pump deliberately stops when the last remote
|
||||
//! workspace closes; this link must outlive that — a purely local session is
|
||||
//! the *common* case — so [`LocalLink::install`] runs its own forever loop at
|
||||
//! the same cadence. Each turn supervises the connection (reconnecting on the
|
||||
//! same 1/2/4/…/30 s backoff a remote machine gets — the daemon may be
|
||||
//! restarting or upgrading, and the GUI auto-spawns it, so "down" is always
|
||||
//! transient) and drains the shared event queue, so local pushes are delivered
|
||||
//! even when the remote pump is parked. Events land in that queue under
|
||||
//! [`HostId::LOCAL`](tty7_core::host::HostId::LOCAL): the pump drains one
|
||||
//! queue and machines differ only by id, which is the same-shape-everywhere
|
||||
//! the whole design is after.
|
||||
//!
|
||||
//! # Both platforms
|
||||
//!
|
||||
//! The dial is the one part that differs, and only in its first line: a Unix
|
||||
//! socket where there are Unix sockets, and the same token-checked loopback
|
||||
//! endpoint the pane dialect uses on Windows (see
|
||||
//! [`tty7_core::daemon::transport`]). Everything above `connect_blocking` —
|
||||
//! supervision, backoff, the event queue, the tree sync that rides this link —
|
||||
//! is one code path, because a machine's tree is what a window's layout *is*
|
||||
//! and a platform without it is a platform where tabs do not come back.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{App, Global};
|
||||
use tty7_core::daemon::control::ControlClient;
|
||||
|
||||
use crate::ui::remote_workspace::Backoff;
|
||||
|
||||
/// The link, and the schedule for getting it back.
|
||||
#[derive(Default)]
|
||||
pub struct LocalLink {
|
||||
client: Option<Arc<ControlClient>>,
|
||||
backoff: Backoff,
|
||||
/// When the next attempt is due. `None` while the link is up or an
|
||||
/// attempt is in flight.
|
||||
next_attempt: Option<std::time::Instant>,
|
||||
attempting: bool,
|
||||
/// Whether the forever loop is already running, so `install` is idempotent.
|
||||
pumping: bool,
|
||||
}
|
||||
|
||||
impl Global for LocalLink {}
|
||||
|
||||
impl LocalLink {
|
||||
/// Start supervising the local link. Called once at startup; safe to call
|
||||
/// again (the loop is a singleton).
|
||||
pub fn install(cx: &mut App) {
|
||||
// Local pushes need the same somewhere-to-go the remote ones have,
|
||||
// and this loop may be the only one draining it.
|
||||
crate::ui::remote_workspace::install_event_observer();
|
||||
let link = cx.default_global::<LocalLink>();
|
||||
if link.pumping {
|
||||
return;
|
||||
}
|
||||
link.pumping = true;
|
||||
cx.spawn(async move |cx| {
|
||||
loop {
|
||||
cx.update(|cx| {
|
||||
Self::tick(cx);
|
||||
crate::ui::remote_workspace::drain_events(cx);
|
||||
});
|
||||
cx.background_executor()
|
||||
.timer(crate::ui::remote_workspace::PUMP_TICK)
|
||||
.await;
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// The live control client for this machine's daemon, if there is one.
|
||||
///
|
||||
/// `None` is always transient — the supervisor is already reconnecting —
|
||||
/// so callers treat it exactly like an unreachable remote: skip the
|
||||
/// operation, or queue nothing and rely on the full pull that follows a
|
||||
/// reconnect.
|
||||
pub fn client(cx: &mut App) -> Option<Arc<ControlClient>> {
|
||||
let link = cx.default_global::<LocalLink>();
|
||||
link.client.as_ref().filter(|c| c.is_connected()).cloned()
|
||||
}
|
||||
|
||||
/// One supervision step: notice a dead link, drop it, and schedule or
|
||||
/// launch the next attempt on the backoff.
|
||||
fn tick(cx: &mut App) {
|
||||
let now = std::time::Instant::now();
|
||||
let link = cx.default_global::<LocalLink>();
|
||||
if link.attempting {
|
||||
return;
|
||||
}
|
||||
if let Some(client) = &link.client {
|
||||
if client.is_connected() {
|
||||
return;
|
||||
}
|
||||
log::info!("lost the control link to the local daemon; reconnecting");
|
||||
link.client = None;
|
||||
}
|
||||
match link.next_attempt {
|
||||
// Never attempted at all: due now. The daemon is normally already
|
||||
// up (main spawns it before the first window), so the first tick
|
||||
// should connect, not start a schedule.
|
||||
None if link.backoff.attempt() == 0 => {}
|
||||
None => {
|
||||
link.next_attempt = Some(now + link.backoff.delay());
|
||||
return;
|
||||
}
|
||||
Some(at) if at > now => return,
|
||||
Some(_) => {}
|
||||
}
|
||||
link.next_attempt = None;
|
||||
link.attempting = true;
|
||||
let _ = link.backoff.advance();
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let connected = cx
|
||||
.background_executor()
|
||||
.spawn(async move { connect_blocking() })
|
||||
.await;
|
||||
cx.update(|cx| {
|
||||
let link = cx.default_global::<LocalLink>();
|
||||
link.attempting = false;
|
||||
match connected {
|
||||
Ok(client) => {
|
||||
log::info!("control link to the local daemon is up");
|
||||
link.client = Some(client);
|
||||
link.backoff.reset();
|
||||
link.next_attempt = None;
|
||||
// Every fresh link starts with a full pull — deltas
|
||||
// only advance a mirror that has a base to advance.
|
||||
crate::ui::machine_mirror::MachineMirrors::refresh(
|
||||
cx,
|
||||
tty7_core::host::HostId::LOCAL,
|
||||
);
|
||||
// …and re-runs every local window's sync: a window
|
||||
// built while this link was still dialing is parked
|
||||
// `Unprimed { dirty }` with nothing else scheduled to
|
||||
// wake it (see `tree_sync::on_link_up`).
|
||||
crate::ui::tree_sync::on_link_up(cx, tty7_core::host::HostId::LOCAL);
|
||||
}
|
||||
Err(e) => {
|
||||
// The next tick schedules the following attempt off
|
||||
// the already-advanced backoff.
|
||||
log::debug!("local control link attempt failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
}
|
||||
|
||||
/// Dial the local daemon's control endpoint and shake hands. **Blocking**; runs
|
||||
/// on the background executor.
|
||||
///
|
||||
/// `ensure_running` first, because the daemon is the GUI's own child in the
|
||||
/// common case: on a cold start this races the daemon binding its listener,
|
||||
/// and the backoff absorbs the one or two attempts that lose the race.
|
||||
fn connect_blocking() -> std::io::Result<Arc<ControlClient>> {
|
||||
use tty7_core::daemon::control::ControlHello;
|
||||
|
||||
crate::daemon::spawn::ensure_running().map_err(std::io::Error::other)?;
|
||||
let hello = ControlHello::host_rpc(
|
||||
uuid::Uuid::new_v4().to_string(),
|
||||
// Its own label rather than this machine's hostname: if this session
|
||||
// is ever preempted, "this computer" is the useful thing to show —
|
||||
// the hostname would name the machine the user is already at.
|
||||
"this computer",
|
||||
);
|
||||
let sink: tty7_core::daemon::control::EventSink = Box::new(local_event_sink);
|
||||
// The one platform difference: which kind of stream carries the dialect.
|
||||
#[cfg(unix)]
|
||||
let client = ControlClient::over_unix(
|
||||
std::os::unix::net::UnixStream::connect(tty7_core::host::server::control_socket_path()?)?,
|
||||
&hello,
|
||||
sink,
|
||||
)?;
|
||||
// Loopback TCP with the daemon's token as a preamble — the access boundary
|
||||
// Windows has instead of socket permissions; `connect_control` presents it.
|
||||
#[cfg(windows)]
|
||||
let client =
|
||||
ControlClient::over_tcp(tty7_core::host::server::connect_control()?, &hello, sink)?;
|
||||
Ok(Arc::new(client))
|
||||
}
|
||||
|
||||
/// Local daemon pushes land in the same process-wide observer as every remote
|
||||
/// machine's, attributed to [`HostId::LOCAL`](tty7_core::host::HostId::LOCAL).
|
||||
fn local_event_sink(event: tty7_core::daemon::control::ControlEvent) {
|
||||
tty7_core::daemon::control::observe_event(tty7_core::host::HostId::LOCAL, event);
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
//! A per-machine mirror of each daemon's workspace tree, for the surfaces that
|
||||
//! read *about* workspaces without showing them.
|
||||
//!
|
||||
//! The machine's tree is the layout authority, so anything the client used to
|
||||
//! answer from its own saved layout — a picker row's name, the "3 panes"
|
||||
//! count, which pane ids a workspace claims — has to come from the tree now.
|
||||
//! The windows that *show* a workspace already hold a per-window mirror
|
||||
//! ([`crate::ui::tree_sync`]); this global is the read model for everything
|
||||
//! else: the switcher, the Window menu, the title bar, the liveness sweep.
|
||||
//!
|
||||
//! # How it stays current
|
||||
//!
|
||||
//! One [`Machine`] per [`HostId`], filled by a `MachineGet` when a machine's
|
||||
//! control link comes up and advanced from there by the same
|
||||
//! [`LayoutDelta`] stream the windows consume — plus
|
||||
//! [`note_synced_workspace`], because origin exclusion means this client never
|
||||
//! hears its **own** operations back, and the per-window mirror they advanced
|
||||
//! is the only other record of what they did.
|
||||
//!
|
||||
//! A delta that will not apply (a machine the pull has not answered for yet, a
|
||||
//! tab it never heard of) marks nothing broken: the mirror re-pulls the whole
|
||||
//! machine, exactly like a drifted window does.
|
||||
//!
|
||||
//! # It may be behind, and that is allowed
|
||||
//!
|
||||
//! Against the local daemon the first pull lands within milliseconds of
|
||||
//! launch, so the picker's loading gap is about one frame. A machine that is
|
||||
//! unreachable keeps its last pulled state for the rest of the process — stale
|
||||
//! names beat no names — and a machine never reached this session simply has
|
||||
//! no entry, which readers render as the not-knowing they are in.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gpui::{App, Global};
|
||||
use tty7_core::core::machine::{LayoutDelta, Machine, PaneRecord, Tab, TabId, Workspace};
|
||||
use tty7_core::daemon::control::{ControlRequest, ReplyOk};
|
||||
use tty7_core::host::HostId;
|
||||
|
||||
use crate::core::session::WorkspaceId;
|
||||
|
||||
/// Every machine's last known tree, by the machine.
|
||||
#[derive(Default)]
|
||||
pub struct MachineMirrors {
|
||||
machines: HashMap<HostId, Machine>,
|
||||
/// Hosts with a `MachineGet` in flight, so a burst of triggers costs one
|
||||
/// round trip.
|
||||
pulling: Vec<HostId>,
|
||||
}
|
||||
|
||||
impl Global for MachineMirrors {}
|
||||
|
||||
impl MachineMirrors {
|
||||
/// The last pulled tree for `host`, or `None` when no pull has answered
|
||||
/// yet this session. Read-only; renders may call it every frame.
|
||||
pub fn machine(cx: &App, host: HostId) -> Option<&Machine> {
|
||||
cx.try_global::<Self>()?.machines.get(&host)
|
||||
}
|
||||
|
||||
/// Whether `host`'s tree has been pulled at all — the "loading" /
|
||||
/// "known but empty" distinction a picker wants to draw.
|
||||
pub fn ready(cx: &App, host: HostId) -> bool {
|
||||
Self::machine(cx, host).is_some()
|
||||
}
|
||||
|
||||
/// Pull `host`'s whole tree in the background and install it. Cheap to
|
||||
/// call whenever a link comes up or a delta refuses to apply; concurrent
|
||||
/// triggers coalesce into one round trip.
|
||||
pub fn refresh(cx: &mut App, host: HostId) {
|
||||
// A peer that does not advertise `machine-tree` (a server with no
|
||||
// home directory for one) has no tree to pull; asking anyway costs a
|
||||
// round trip per trigger to hear the same refusal. Reads keep their
|
||||
// "never pulled" answer, which renders as not knowing.
|
||||
let client = match crate::ui::tree_sync::tree_control_for(cx, host) {
|
||||
crate::ui::tree_sync::TreeLink::Ready(client) => client,
|
||||
crate::ui::tree_sync::TreeLink::Unserved => {
|
||||
log::debug!("not pulling {host:?}: its server does not serve the machine tree");
|
||||
return;
|
||||
}
|
||||
crate::ui::tree_sync::TreeLink::Down => return,
|
||||
};
|
||||
let mirrors = cx.default_global::<Self>();
|
||||
if mirrors.pulling.contains(&host) {
|
||||
return;
|
||||
}
|
||||
mirrors.pulling.push(host);
|
||||
cx.spawn(async move |cx| {
|
||||
let pulled = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
match client.call(ControlRequest::MachineGet) {
|
||||
Ok(ReplyOk::MachineTree(machine)) => Some(machine),
|
||||
Ok(other) => {
|
||||
log::warn!("MachineGet answered {other:?}");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("could not pull the machine tree: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
cx.update(|cx| {
|
||||
let mirrors = cx.default_global::<Self>();
|
||||
mirrors.pulling.retain(|h| *h != host);
|
||||
if let Some(machine) = pulled {
|
||||
mirrors.machines.insert(host, *machine);
|
||||
cx.refresh_windows();
|
||||
}
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Install a freshly pulled tree — for the paths that already hold one
|
||||
/// (a window's hydration pulls `MachineGet` anyway).
|
||||
///
|
||||
/// Repaints, like [`refresh`](Self::refresh)'s landing does: every workspace
|
||||
/// name, pane count and liveness dot on screen reads this global, and a
|
||||
/// pull that lands without a repaint leaves the chrome a frame (or, on a
|
||||
/// quiet screen, indefinitely) behind the tree it is describing.
|
||||
pub fn install(cx: &mut App, host: HostId, machine: Machine) {
|
||||
cx.default_global::<Self>().machines.insert(host, machine);
|
||||
cx.refresh_windows();
|
||||
}
|
||||
|
||||
/// Advance `host`'s mirror by one delta about the workspace `key` names.
|
||||
///
|
||||
/// A delta that names state the mirror does not hold re-pulls the machine
|
||||
/// whole; a delta arriving before the first pull is dropped, because that
|
||||
/// pull's answer already includes it.
|
||||
pub fn apply_delta(cx: &mut App, host: HostId, key: &str, delta: &LayoutDelta) {
|
||||
let Ok(id) = key.parse::<WorkspaceId>() else {
|
||||
return;
|
||||
};
|
||||
let applied = match cx.default_global::<Self>().machines.get_mut(&host) {
|
||||
Some(machine) => apply(machine, id, delta),
|
||||
None => true,
|
||||
};
|
||||
if !applied {
|
||||
log::debug!("machine mirror for {host:?} fell behind; re-pulling");
|
||||
Self::refresh(cx, host);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the post-state of this client's own operations on `machine_ws` —
|
||||
/// the half of the history origin exclusion keeps out of the delta stream.
|
||||
/// A workspace the mirror has not seen is created; `None` tabs leave the
|
||||
/// structure alone (a label-only op).
|
||||
pub fn note_synced_workspace(
|
||||
cx: &mut App,
|
||||
host: HostId,
|
||||
machine_ws: WorkspaceId,
|
||||
tabs: Vec<Tab>,
|
||||
active: Option<TabId>,
|
||||
) {
|
||||
let Some(machine) = cx.default_global::<Self>().machines.get_mut(&host) else {
|
||||
return;
|
||||
};
|
||||
let ws = match machine.workspaces.iter_mut().find(|w| w.id == machine_ws) {
|
||||
Some(ws) => ws,
|
||||
None => {
|
||||
machine.workspaces.push(Workspace {
|
||||
id: machine_ws,
|
||||
..Workspace::default()
|
||||
});
|
||||
machine.workspaces.last_mut().expect("just pushed")
|
||||
}
|
||||
};
|
||||
ws.tabs = tabs;
|
||||
ws.active_tab = active;
|
||||
}
|
||||
|
||||
/// Fold in a workspace-level operation this client just fired
|
||||
/// ([`crate::ui::tree_sync::fire_workspace_op`]) — same reason as
|
||||
/// [`note_synced_workspace`]: the writer never hears its own echo.
|
||||
pub fn note_workspace_op(cx: &mut App, host: HostId, request: &ControlRequest) {
|
||||
let Some(machine) = cx.default_global::<Self>().machines.get_mut(&host) else {
|
||||
return;
|
||||
};
|
||||
match request {
|
||||
ControlRequest::WorkspaceRename { workspace, name } => {
|
||||
if let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == *workspace) {
|
||||
ws.name = name.clone();
|
||||
}
|
||||
}
|
||||
ControlRequest::WorkspaceTouch { workspace } => {
|
||||
if let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == *workspace) {
|
||||
ws.last_active = crate::ui::home::now_secs();
|
||||
}
|
||||
}
|
||||
ControlRequest::WorkspaceRemove { workspace } => {
|
||||
machine.workspaces.retain(|w| w.id != *workspace);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance one machine's copy by one delta. `false` means the delta names
|
||||
/// state the mirror does not hold and the caller should re-pull.
|
||||
fn apply(machine: &mut Machine, workspace: WorkspaceId, delta: &LayoutDelta) -> bool {
|
||||
// The two deltas that do not require the workspace to exist yet.
|
||||
match delta {
|
||||
LayoutDelta::WorkspaceCreated { workspace: ws } => {
|
||||
machine.workspaces.retain(|w| w.id != ws.id);
|
||||
machine.workspaces.push(ws.clone());
|
||||
return true;
|
||||
}
|
||||
LayoutDelta::WorkspaceDeleted => {
|
||||
machine.workspaces.retain(|w| w.id != workspace);
|
||||
return true;
|
||||
}
|
||||
// Facts about a pane are registry-wide; the workspace key only says
|
||||
// who referenced it. Upserted rather than matched, because the record
|
||||
// may have been born from another client's op this mirror never saw.
|
||||
LayoutDelta::PaneFacts { pane } => {
|
||||
match machine.panes.iter_mut().find(|p| p.id == pane.id) {
|
||||
Some(record) => *record = pane.clone(),
|
||||
None => machine.panes.push(pane.clone()),
|
||||
}
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let Some(ws) = machine.workspaces.iter_mut().find(|w| w.id == workspace) else {
|
||||
return false;
|
||||
};
|
||||
match delta {
|
||||
LayoutDelta::WorkspaceCreated { .. }
|
||||
| LayoutDelta::WorkspaceDeleted
|
||||
| LayoutDelta::PaneFacts { .. } => unreachable!("handled above"),
|
||||
LayoutDelta::WorkspaceRenamed { name } => {
|
||||
ws.name = name.clone();
|
||||
true
|
||||
}
|
||||
LayoutDelta::WorkspaceTouched { last_active } => {
|
||||
ws.last_active = *last_active;
|
||||
true
|
||||
}
|
||||
LayoutDelta::ActiveTabChanged { tab } => {
|
||||
ws.active_tab = Some(*tab);
|
||||
true
|
||||
}
|
||||
LayoutDelta::TabCreated { at, tab } => {
|
||||
// Deltas and full pulls have no ordering barrier: a create that
|
||||
// straddles a pull arrives *after* the snapshot that already
|
||||
// carries its tab. Replace-by-id (the `WorkspaceCreated` retain
|
||||
// above is the precedent) rather than insert twice.
|
||||
ws.tabs.retain(|t| t.id != tab.id);
|
||||
let at = (*at).min(ws.tabs.len());
|
||||
ws.tabs.insert(at, tab.clone());
|
||||
true
|
||||
}
|
||||
LayoutDelta::TabClosed { tab } => {
|
||||
let before = ws.tabs.len();
|
||||
ws.tabs.retain(|t| t.id != *tab);
|
||||
if ws.tabs.is_empty() {
|
||||
ws.active_tab = None;
|
||||
}
|
||||
ws.tabs.len() != before
|
||||
}
|
||||
LayoutDelta::TabRenamed { tab, name } => {
|
||||
let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else {
|
||||
return false;
|
||||
};
|
||||
t.name = name.clone();
|
||||
true
|
||||
}
|
||||
LayoutDelta::TabRegrouped { tab, group } => {
|
||||
let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else {
|
||||
return false;
|
||||
};
|
||||
t.sidebar_group = group.clone();
|
||||
true
|
||||
}
|
||||
LayoutDelta::TabMoved { tab, to } => {
|
||||
let Some(from) = ws.tabs.iter().position(|t| t.id == *tab) else {
|
||||
return false;
|
||||
};
|
||||
let moved = ws.tabs.remove(from);
|
||||
ws.tabs.insert((*to).min(ws.tabs.len()), moved);
|
||||
true
|
||||
}
|
||||
LayoutDelta::TabRestructured { tab, pane } => {
|
||||
let Some(t) = ws.tabs.iter_mut().find(|t| t.id == tab.id) else {
|
||||
return false;
|
||||
};
|
||||
*t = tab.clone();
|
||||
if let Some(pane) = pane {
|
||||
match machine.panes.iter_mut().find(|p| p.id == pane.id) {
|
||||
Some(record) => *record = pane.clone(),
|
||||
None => machine.panes.push(pane.clone()),
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
LayoutDelta::RatioChanged { tab, path, ratio } => {
|
||||
let Some(t) = ws.tabs.iter_mut().find(|t| t.id == *tab) else {
|
||||
return false;
|
||||
};
|
||||
match t.root.descend_mut(path) {
|
||||
Some(tty7_core::core::machine::PaneNode::Split { ratio: r, .. }) => {
|
||||
*r = *ratio;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reading a client entry's display facts off its machine's mirror
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The tree workspace a client entry points at, with the pane registry it
|
||||
/// reads records from. `None` while the machine has not been pulled (or no
|
||||
/// longer lists the workspace).
|
||||
fn view_of<'a>(
|
||||
cx: &'a App,
|
||||
entry: &crate::core::session::WindowView,
|
||||
) -> Option<(&'a Workspace, &'a [PaneRecord])> {
|
||||
let machine = MachineMirrors::machine(cx, entry.host_id())?;
|
||||
let machine_ws = entry.host.as_ref().map(|r| r.workspace).unwrap_or(entry.id);
|
||||
let ws = machine.workspaces.iter().find(|w| w.id == machine_ws)?;
|
||||
Some((ws, &machine.panes))
|
||||
}
|
||||
|
||||
/// What the picker and the window title call `entry`: the user-set name, else
|
||||
/// derived from the tree's repo groups and cwds.
|
||||
///
|
||||
/// Falls back to
|
||||
/// [`WindowView::label`](crate::core::session::WindowView::label) — what the
|
||||
/// machine last said, before it
|
||||
/// stopped answering. The tree wins whenever it answers; the hint is for the
|
||||
/// rows the picker exists to offer, on machines that are asleep. `None` only
|
||||
/// when this client has never seen the workspace named at all, which is a
|
||||
/// brand-new entry and nothing a user is choosing between.
|
||||
pub fn display_name(cx: &App, entry: &crate::core::session::WindowView) -> Option<String> {
|
||||
match view_of(cx, entry) {
|
||||
Some((ws, panes)) => Some(display_name_of(ws, panes)),
|
||||
None => entry.label.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A tree workspace's label: the user-set name, else the repository most of
|
||||
/// its tabs live in, else the first pane's directory, else `"Untitled"`.
|
||||
pub fn display_name_of(ws: &Workspace, panes: &[PaneRecord]) -> String {
|
||||
if let Some(name) = ws.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
|
||||
return name.to_string();
|
||||
}
|
||||
subject_path_of(ws, panes)
|
||||
.and_then(|path| {
|
||||
std::path::Path::new(&path)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
})
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "Untitled".to_string())
|
||||
}
|
||||
|
||||
/// The path a workspace is *about*: the repo group most tabs belong to (ties
|
||||
/// toward the earliest tab), else the first pane's cwd. What the picker's dim
|
||||
/// subtitle shows, and what [`display_name_of`] takes the basename of.
|
||||
pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option<String> {
|
||||
let mut counts: Vec<(&str, usize)> = Vec::new();
|
||||
for group in ws.tabs.iter().filter_map(|t| t.sidebar_group.as_deref()) {
|
||||
match counts.iter_mut().find(|(g, _)| *g == group) {
|
||||
Some((_, n)) => *n += 1,
|
||||
None => counts.push((group, 1)),
|
||||
}
|
||||
}
|
||||
let dominant = counts.into_iter().max_by_key(|(_, n)| *n).map(|(g, _)| g);
|
||||
let first_cwd = ws
|
||||
.tabs
|
||||
.iter()
|
||||
.flat_map(|t| t.root.pane_ids())
|
||||
.find_map(|id| {
|
||||
panes
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.and_then(|p| p.cwd.as_deref())
|
||||
});
|
||||
dominant.or(first_cwd).map(str::to_string)
|
||||
}
|
||||
|
||||
/// [`display_name`] looked up by the client's workspace id, with the shared
|
||||
/// not-knowing fallback — for the sites that hold an id rather than an entry.
|
||||
pub fn display_name_for(cx: &App, client_ws: WorkspaceId) -> Option<String> {
|
||||
let entry = crate::core::session::WorkspaceStore::all(cx).get(client_ws)?;
|
||||
display_name(cx, entry)
|
||||
}
|
||||
|
||||
/// [`subject_path_of`] for a client entry, falling back to the stamped hint for
|
||||
/// the same reason [`display_name`] does.
|
||||
pub fn subject_path(cx: &App, entry: &crate::core::session::WindowView) -> Option<String> {
|
||||
match view_of(cx, entry) {
|
||||
Some((ws, panes)) => subject_path_of(ws, panes).or_else(|| entry.subject.clone()),
|
||||
None => entry.subject.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The pair a client entry should carry on file, read off its machine's mirror —
|
||||
/// for [`WorkspaceStore::record_geometry`](crate::core::session::WorkspaceStore::record_geometry)
|
||||
/// to stamp. `None` for a machine that has not answered: a hint is only ever
|
||||
/// replaced by something better, never blanked by not knowing.
|
||||
pub fn display_hint(
|
||||
cx: &App,
|
||||
entry: &crate::core::session::WindowView,
|
||||
) -> Option<(String, Option<String>)> {
|
||||
let (ws, panes) = view_of(cx, entry)?;
|
||||
Some((display_name_of(ws, panes), subject_path_of(ws, panes)))
|
||||
}
|
||||
|
||||
/// Every pane id `entry`'s tree claims on its machine. `None` when the
|
||||
/// machine's tree has not been pulled — which a caller about to state a fact
|
||||
/// ("3 running sessions will be ended") must render as not knowing, not as
|
||||
/// zero.
|
||||
pub fn pane_ids(cx: &App, entry: &crate::core::session::WindowView) -> Option<Vec<u64>> {
|
||||
let (ws, _) = match view_of(cx, entry) {
|
||||
Some(view) => view,
|
||||
// A pulled machine that no longer lists the workspace *is* an answer:
|
||||
// it claims nothing.
|
||||
None if MachineMirrors::ready(cx, entry.host_id()) => return Some(Vec::new()),
|
||||
None => return None,
|
||||
};
|
||||
Some(ws.tabs.iter().flat_map(|t| t.root.pane_ids()).collect())
|
||||
}
|
||||
|
||||
/// How many terminals `entry` holds across every tab, per its machine's tree.
|
||||
pub fn pane_count(cx: &App, entry: &crate::core::session::WindowView) -> Option<usize> {
|
||||
pane_ids(cx, entry).map(|ids| ids.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tty7_core::core::machine::{Axis, PaneNode, Tab, TabId};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn machine_with(ws: Workspace) -> Machine {
|
||||
Machine {
|
||||
workspaces: vec![ws],
|
||||
panes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn leaf_tab(pane: u64) -> Tab {
|
||||
Tab::leaf(pane)
|
||||
}
|
||||
|
||||
/// A machine that is not answering still has to produce a row a user can
|
||||
/// choose: the picker's whole job is offering workspaces on machines that
|
||||
/// are asleep, and "Untitled" with a blank subtitle is not an offer. So the
|
||||
/// stamped hint stands in until a pull lands, and the tree wins the moment
|
||||
/// one does.
|
||||
#[gpui::test]
|
||||
fn an_unpulled_machine_falls_back_to_the_stamped_label(cx: &mut gpui::TestAppContext) {
|
||||
use crate::core::session::{WindowView, WindowViews, WorkspaceStore};
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut view = WindowView::default();
|
||||
view.label = Some("api".into());
|
||||
view.subject = Some("/repo/api".into());
|
||||
let id = view.id;
|
||||
let entry = view.clone();
|
||||
WorkspaceStore::install_for_test(
|
||||
cx,
|
||||
WindowViews {
|
||||
views: vec![view],
|
||||
active: None,
|
||||
},
|
||||
);
|
||||
|
||||
// Nothing pulled: the hint is what the row says.
|
||||
assert_eq!(display_name(cx, &entry).as_deref(), Some("api"));
|
||||
assert_eq!(subject_path(cx, &entry).as_deref(), Some("/repo/api"));
|
||||
assert!(
|
||||
display_hint(cx, &entry).is_none(),
|
||||
"and a machine that has not answered contributes no new hint"
|
||||
);
|
||||
|
||||
// The tree answers, and outranks it.
|
||||
let mut tree = Workspace {
|
||||
id,
|
||||
name: Some("web".into()),
|
||||
..Workspace::default()
|
||||
};
|
||||
tree.tabs = vec![leaf_tab(1)];
|
||||
MachineMirrors::install(cx, HostId::LOCAL, machine_with(tree));
|
||||
assert_eq!(display_name(cx, &entry).as_deref(), Some("web"));
|
||||
assert_eq!(
|
||||
display_hint(cx, &entry).map(|(label, _)| label).as_deref(),
|
||||
Some("web"),
|
||||
"which is what the next save stamps"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_workspace_created_delta_lands_whole_and_a_deleted_one_removes_it() {
|
||||
let mut machine = Machine::default();
|
||||
let ws = Workspace::default();
|
||||
let id = ws.id;
|
||||
assert!(apply(
|
||||
&mut machine,
|
||||
id,
|
||||
&LayoutDelta::WorkspaceCreated { workspace: ws },
|
||||
));
|
||||
assert_eq!(machine.workspaces.len(), 1);
|
||||
assert!(apply(&mut machine, id, &LayoutDelta::WorkspaceDeleted));
|
||||
assert!(machine.workspaces.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_deltas_advance_the_mirrored_tree() {
|
||||
let ws = Workspace::default();
|
||||
let id = ws.id;
|
||||
let mut machine = machine_with(ws);
|
||||
let tab = leaf_tab(1);
|
||||
let tab_id = tab.id;
|
||||
assert!(apply(
|
||||
&mut machine,
|
||||
id,
|
||||
&LayoutDelta::TabCreated { at: 0, tab },
|
||||
));
|
||||
let restructured = Tab {
|
||||
id: tab_id,
|
||||
name: None,
|
||||
sidebar_group: None,
|
||||
root: PaneNode::Split {
|
||||
axis: Axis::Vertical,
|
||||
ratio: 0.5,
|
||||
a: Box::new(PaneNode::Leaf { pane: 1 }),
|
||||
b: Box::new(PaneNode::Leaf { pane: 2 }),
|
||||
},
|
||||
};
|
||||
assert!(apply(
|
||||
&mut machine,
|
||||
id,
|
||||
&LayoutDelta::TabRestructured {
|
||||
tab: restructured,
|
||||
pane: Some(PaneRecord::new(2)),
|
||||
},
|
||||
));
|
||||
let ws = &machine.workspaces[0];
|
||||
assert_eq!(ws.tabs[0].root.pane_ids(), vec![1, 2]);
|
||||
assert_eq!(
|
||||
machine.panes.len(),
|
||||
1,
|
||||
"the rider pane record is upserted into the registry"
|
||||
);
|
||||
}
|
||||
|
||||
/// Deltas and full pulls have no ordering barrier: a `TabCreated` that
|
||||
/// straddles a `MachineGet` arrives after a snapshot that already carries
|
||||
/// its tab. Applying it must replace by id, not insert a second copy.
|
||||
#[test]
|
||||
fn a_tab_created_delta_that_straddled_a_pull_lands_once() {
|
||||
let ws = Workspace::default();
|
||||
let id = ws.id;
|
||||
let mut machine = machine_with(ws);
|
||||
let delta = LayoutDelta::TabCreated {
|
||||
at: 0,
|
||||
tab: leaf_tab(1),
|
||||
};
|
||||
assert!(apply(&mut machine, id, &delta));
|
||||
assert!(apply(&mut machine, id, &delta));
|
||||
assert_eq!(
|
||||
machine.workspaces[0].tabs.len(),
|
||||
1,
|
||||
"the second application is the pull/delta overlap, not a second tab"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delta_about_a_tab_the_mirror_never_saw_asks_for_a_repull() {
|
||||
let ws = Workspace::default();
|
||||
let id = ws.id;
|
||||
let mut machine = machine_with(ws);
|
||||
assert!(
|
||||
!apply(
|
||||
&mut machine,
|
||||
id,
|
||||
&LayoutDelta::TabRenamed {
|
||||
tab: TabId::new(),
|
||||
name: Some("x".into()),
|
||||
},
|
||||
),
|
||||
"an unappliable delta must say so, so the caller re-pulls"
|
||||
);
|
||||
// …and so does one about a workspace the machine does not list.
|
||||
assert!(!apply(
|
||||
&mut machine,
|
||||
WorkspaceId::new(),
|
||||
&LayoutDelta::WorkspaceRenamed { name: None },
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_facts_upsert_the_registry_even_for_a_pane_born_elsewhere() {
|
||||
let mut machine = Machine::default();
|
||||
let mut record = PaneRecord::new(7);
|
||||
record.cwd = Some("/work".into());
|
||||
assert!(apply(
|
||||
&mut machine,
|
||||
WorkspaceId::new(),
|
||||
&LayoutDelta::PaneFacts {
|
||||
pane: record.clone(),
|
||||
},
|
||||
));
|
||||
record.live = true;
|
||||
assert!(apply(
|
||||
&mut machine,
|
||||
WorkspaceId::new(),
|
||||
&LayoutDelta::PaneFacts { pane: record },
|
||||
));
|
||||
assert_eq!(machine.panes.len(), 1, "updated in place, not duplicated");
|
||||
assert!(machine.panes[0].live);
|
||||
}
|
||||
|
||||
/// The precedence `Workspace::display_name` always had, read off the tree:
|
||||
/// user name, then the dominant repo group, then the first pane's cwd.
|
||||
#[test]
|
||||
fn display_names_derive_from_the_tree_with_the_session_precedence() {
|
||||
let mut ws = Workspace::default();
|
||||
let panes = vec![PaneRecord {
|
||||
cwd: Some("/home/me/scratch".into()),
|
||||
..PaneRecord::new(1)
|
||||
}];
|
||||
ws.tabs = vec![leaf_tab(1)];
|
||||
assert_eq!(display_name_of(&ws, &panes), "scratch");
|
||||
|
||||
ws.tabs[0].sidebar_group = Some("/repo/tty7".into());
|
||||
assert_eq!(display_name_of(&ws, &panes), "tty7");
|
||||
|
||||
ws.name = Some(" Release prep ".into());
|
||||
assert_eq!(display_name_of(&ws, &panes), "Release prep");
|
||||
|
||||
assert_eq!(display_name_of(&Workspace::default(), &[]), "Untitled");
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ pub mod host_ops;
|
||||
#[allow(dead_code)]
|
||||
pub mod host_registry;
|
||||
pub mod keymap;
|
||||
pub mod local_link;
|
||||
pub mod machine_mirror;
|
||||
pub mod palette;
|
||||
pub mod pane;
|
||||
pub mod pending_pane;
|
||||
@@ -42,5 +44,6 @@ pub mod tab_sidebar;
|
||||
pub mod tab_strip;
|
||||
pub mod theme;
|
||||
pub mod tray;
|
||||
pub mod tree_sync;
|
||||
pub mod windows;
|
||||
pub mod worktree_prompt;
|
||||
|
||||
+11
-2
@@ -716,12 +716,21 @@ impl Pane<PaneSlot> {
|
||||
window.refresh();
|
||||
}
|
||||
});
|
||||
// End the drag on release.
|
||||
// End the drag on release — and persist the ratio
|
||||
// it landed on. The drag itself only moves the
|
||||
// shared cell; without this save the new ratio
|
||||
// reached disk (and now the machine's tree) only as
|
||||
// a passenger on some later structural change.
|
||||
window.on_mouse_event({
|
||||
let dragging = dragging.clone();
|
||||
move |_ev: &MouseUpEvent, _phase, window, _cx| {
|
||||
move |_ev: &MouseUpEvent, _phase, window, cx| {
|
||||
if dragging.get() {
|
||||
dragging.set(false);
|
||||
if let Some(app) =
|
||||
crate::ui::windows::WindowRegistry::app_in(cx, window)
|
||||
{
|
||||
app.update(cx, |app, cx| app.save_session(cx));
|
||||
}
|
||||
window.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
+73
-136
@@ -15,7 +15,7 @@
|
||||
//! | 2 | Resolve one into a self-contained SSH spec | [`spec_for`] |
|
||||
//! | 3 | Open a routed control connection through the local daemon | [`connect_blocking`] |
|
||||
//! | 4 | Read the machine's own workspace list | [`rows_from_list`] |
|
||||
//! | 5 | Hold the connection for the workspaces bound to it | [`RemoteConnections`] |
|
||||
//! | 5 | Hold the connection for the workspaces bound to it | [`HostLinks`] |
|
||||
//!
|
||||
//! ## Machines are configured once
|
||||
//!
|
||||
@@ -42,7 +42,6 @@ use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{App, Global};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::core::config::Config;
|
||||
use crate::core::session::{RemoteTarget, WorkspaceId};
|
||||
@@ -401,11 +400,11 @@ fn handshake(
|
||||
|
||||
/// Ask a connected machine for its workspaces.
|
||||
pub fn list_workspaces(host: &Arc<RemoteHost>) -> io::Result<Vec<RemoteWorkspaceRow>> {
|
||||
match host.client().call(ControlRequest::WorkspaceList)? {
|
||||
ReplyOk::Json(Value::Array(list)) => Ok(rows_from_list(&list)),
|
||||
match host.client().call(ControlRequest::MachineGet)? {
|
||||
ReplyOk::MachineTree(machine) => Ok(rows_from_machine(&machine)),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("the server answered a workspace list with {other:?}"),
|
||||
format!("the server answered a machine tree with {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -444,38 +443,24 @@ fn client_hostname() -> String {
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RemoteWorkspaceRow {
|
||||
pub id: WorkspaceId,
|
||||
/// Already resolved through `Workspace::display_name`'s rules, so a record
|
||||
/// with no user-set name still reads as its repo or directory.
|
||||
/// The user-set name when there is one, else derived from the tabs' repo
|
||||
/// groups and cwds — the same precedence `Workspace::display_name` gives a
|
||||
/// local workspace, computed here from the machine's tree.
|
||||
pub name: String,
|
||||
pub panes: usize,
|
||||
pub last_active: u64,
|
||||
/// The raw record, kept so opening the row can `apply_remote_json` it
|
||||
/// without a second round trip.
|
||||
pub record: Value,
|
||||
}
|
||||
|
||||
/// Turn a `WorkspaceList` payload into picker rows, newest first.
|
||||
///
|
||||
/// Records the client cannot decode are **skipped, not fatal**: the list is
|
||||
/// written by whichever tty7 last touched that machine, and one record from a
|
||||
/// newer build must not make every other workspace on the box unreachable.
|
||||
pub fn rows_from_list(list: &[Value]) -> Vec<RemoteWorkspaceRow> {
|
||||
let mut rows: Vec<RemoteWorkspaceRow> = list
|
||||
/// Turn a machine's tree into picker rows, newest first.
|
||||
pub fn rows_from_machine(machine: &tty7_core::core::machine::Machine) -> Vec<RemoteWorkspaceRow> {
|
||||
let mut rows: Vec<RemoteWorkspaceRow> = machine
|
||||
.workspaces
|
||||
.iter()
|
||||
.filter_map(|record| {
|
||||
// The remote record is the remote-owned half of a `Workspace`, so it
|
||||
// decodes by merging onto a blank one — which is also what gives us
|
||||
// `display_name` and `pane_count` for free rather than reimplemented.
|
||||
let mut workspace = crate::core::session::Workspace::default();
|
||||
workspace.apply_remote_json(record).ok()?;
|
||||
let id: WorkspaceId = serde_json::from_value(record.get("id")?.clone()).ok()?;
|
||||
Some(RemoteWorkspaceRow {
|
||||
id,
|
||||
name: workspace.display_name(),
|
||||
panes: workspace.pane_count(),
|
||||
last_active: workspace.last_active,
|
||||
record: record.clone(),
|
||||
})
|
||||
.map(|ws| RemoteWorkspaceRow {
|
||||
id: ws.id,
|
||||
name: crate::ui::machine_mirror::display_name_of(ws, &machine.panes),
|
||||
panes: ws.tabs.iter().map(|t| t.root.pane_ids().len()).sum(),
|
||||
last_active: ws.last_active,
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by_key(|row| std::cmp::Reverse(row.last_active));
|
||||
@@ -486,16 +471,21 @@ pub fn rows_from_list(list: &[Value]) -> Vec<RemoteWorkspaceRow> {
|
||||
// 5. Holding the connections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The live remote machines, by [`HostId`].
|
||||
/// The live control links, by [`HostId`] — one per machine, one machine per
|
||||
/// entry.
|
||||
///
|
||||
/// One entry per *machine*, not per workspace — the same granularity the SSH
|
||||
/// connection is pooled at and the same one [`crate::ui::host_registry`] uses,
|
||||
/// so two windows on one box share a connection, a host object and a git-status
|
||||
/// cache. This table holds the concrete [`RemoteHost`] because pushing a layout
|
||||
/// needs its control client; `HostRegistry` holds the same object erased to
|
||||
/// `dyn Host` for the panels.
|
||||
/// The name says the model: every machine this client talks to is reached
|
||||
/// over exactly one control link, and the local machine is a machine like any
|
||||
/// other — its link simply lives in its own global
|
||||
/// ([`LocalLink`](crate::ui::local_link::LocalLink)) because it is in-process
|
||||
/// rather than wire-backed. One entry per *machine*, not per workspace — the
|
||||
/// same granularity the SSH connection is pooled at and the same one
|
||||
/// [`crate::ui::host_registry`] uses, so two windows on one box share a
|
||||
/// connection, a host object and a git-status cache. This table holds the
|
||||
/// concrete [`RemoteHost`] because pushing a layout needs its control client;
|
||||
/// `HostRegistry` holds the same object erased to `dyn Host` for the panels.
|
||||
#[derive(Default)]
|
||||
pub struct RemoteConnections {
|
||||
pub struct HostLinks {
|
||||
hosts: HashMap<HostId, Arc<RemoteHost>>,
|
||||
/// Each machine's `$HOME`, as its handshake reported it.
|
||||
///
|
||||
@@ -511,24 +501,18 @@ pub struct RemoteConnections {
|
||||
homes: HashMap<HostId, PathBuf>,
|
||||
}
|
||||
|
||||
impl Global for RemoteConnections {}
|
||||
impl Global for HostLinks {}
|
||||
|
||||
impl RemoteConnections {
|
||||
impl HostLinks {
|
||||
/// The connection to `id`, if this process has one.
|
||||
pub fn get(cx: &mut App, id: HostId) -> Option<Arc<RemoteHost>> {
|
||||
cx.default_global::<RemoteConnections>()
|
||||
.hosts
|
||||
.get(&id)
|
||||
.cloned()
|
||||
cx.default_global::<HostLinks>().hosts.get(&id).cloned()
|
||||
}
|
||||
|
||||
/// Where a *new* workspace on `id` would start: that machine's own `$HOME`,
|
||||
/// never this client's.
|
||||
pub fn home(cx: &mut App, id: HostId) -> Option<PathBuf> {
|
||||
cx.default_global::<RemoteConnections>()
|
||||
.homes
|
||||
.get(&id)
|
||||
.cloned()
|
||||
cx.default_global::<HostLinks>().homes.get(&id).cloned()
|
||||
}
|
||||
|
||||
/// Record a connection, and register the same object with the host registry
|
||||
@@ -540,14 +524,14 @@ impl RemoteConnections {
|
||||
pub fn insert(cx: &mut App, host: Arc<RemoteHost>, home: PathBuf) {
|
||||
let id = host.id();
|
||||
crate::ui::host_registry::HostRegistry::insert(cx, Arc::clone(&host).into_shared());
|
||||
let table = cx.default_global::<RemoteConnections>();
|
||||
let table = cx.default_global::<HostLinks>();
|
||||
table.hosts.insert(id, host);
|
||||
table.homes.insert(id, home);
|
||||
}
|
||||
|
||||
/// Drop a machine's connection once nothing is using it.
|
||||
pub fn remove(cx: &mut App, id: HostId) {
|
||||
let table = cx.default_global::<RemoteConnections>();
|
||||
let table = cx.default_global::<HostLinks>();
|
||||
table.hosts.remove(&id);
|
||||
table.homes.remove(&id);
|
||||
crate::ui::host_registry::HostRegistry::remove(cx, id);
|
||||
@@ -555,50 +539,10 @@ impl RemoteConnections {
|
||||
|
||||
/// Machines currently connected. Diagnostics and teardown.
|
||||
pub fn len(cx: &mut App) -> usize {
|
||||
cx.default_global::<RemoteConnections>().hosts.len()
|
||||
cx.default_global::<HostLinks>().hosts.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a workspace's layout to the machine that owns it (the
|
||||
/// remote's `workspaces.json` is the authority). Blocking.
|
||||
pub fn put_remote_layout(host: &Arc<RemoteHost>, key: String, record: Value) -> io::Result<()> {
|
||||
host.client()
|
||||
.call(ControlRequest::WorkspacePut {
|
||||
id: key,
|
||||
json: record,
|
||||
})
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Pull one workspace's authoritative record from the machine that owns it.
|
||||
/// Blocking.
|
||||
///
|
||||
/// The read side of the split, and what a
|
||||
/// [`ControlEvent::WorkspaceChanged`](crate::daemon::control::ControlEvent)
|
||||
/// asks for: the event says only *that* a record moved, so the record itself is
|
||||
/// fetched rather than carried. `ErrorKind::NotFound` is a real answer — the
|
||||
/// workspace was deleted on the far side — and is deliberately distinguishable
|
||||
/// from an empty one.
|
||||
pub fn get_remote_layout(host: &Arc<RemoteHost>, key: String) -> io::Result<Value> {
|
||||
match host
|
||||
.client()
|
||||
.call(ControlRequest::WorkspaceGet { id: key })?
|
||||
{
|
||||
ReplyOk::Json(record) => Ok(record),
|
||||
other => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("the server answered a workspace record with {other:?}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Forget a workspace on the machine that owns it. Blocking.
|
||||
pub fn delete_remote_workspace(host: &Arc<RemoteHost>, key: String) -> io::Result<()> {
|
||||
host.client()
|
||||
.call(ControlRequest::WorkspaceDelete { id: key })
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Install consent
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -776,7 +720,7 @@ pub fn register(cx: &mut App) {
|
||||
crate::daemon::router::set_route_auth_responder(Arc::new(GuiRouteAuth));
|
||||
// Touch the globals so the first connect isn't also the first allocation of
|
||||
// the table it writes into, on a thread that is holding a socket open.
|
||||
let _ = RemoteConnections::len(cx);
|
||||
let _ = HostLinks::len(cx);
|
||||
}
|
||||
|
||||
/// The oldest install waiting for an answer, if any.
|
||||
@@ -1271,54 +1215,47 @@ mod tests {
|
||||
assert_eq!(endpoint_label("", "box.local", 22), "box.local");
|
||||
}
|
||||
|
||||
/// The picker's rows come from records the *remote* wrote, so they have to
|
||||
/// survive a record this build cannot read: one bad entry may not hide the
|
||||
/// rest of the machine's workspaces.
|
||||
/// The picker's rows come from the machine's tree: newest first, with a
|
||||
/// name derived the way a local workspace's would be when none is set.
|
||||
#[test]
|
||||
fn rows_skip_undecodable_records_and_sort_newest_first() {
|
||||
fn rows_from_the_tree_sort_newest_first_and_derive_names() {
|
||||
use tty7_core::core::machine::{Machine, PaneRecord, Tab, Workspace};
|
||||
let older = WorkspaceId::new();
|
||||
let newer = WorkspaceId::new();
|
||||
let list = vec![
|
||||
serde_json::json!({
|
||||
"id": older.to_string(),
|
||||
"name": "api",
|
||||
"session": { "tabs": [] },
|
||||
"last_active": 100,
|
||||
}),
|
||||
// No `id` at all — unreadable, and skipped rather than fatal.
|
||||
serde_json::json!({ "name": "broken" }),
|
||||
serde_json::json!({
|
||||
"id": newer.to_string(),
|
||||
"name": "web",
|
||||
"session": { "tabs": [] },
|
||||
"last_active": 500,
|
||||
}),
|
||||
];
|
||||
let rows = rows_from_list(&list);
|
||||
assert_eq!(rows.len(), 2, "the undecodable record is skipped");
|
||||
let machine = Machine {
|
||||
workspaces: vec![
|
||||
Workspace {
|
||||
id: older,
|
||||
name: Some("api".into()),
|
||||
last_active: 100,
|
||||
tabs: vec![Tab::leaf(1)],
|
||||
..Default::default()
|
||||
},
|
||||
Workspace {
|
||||
id: newer,
|
||||
name: None,
|
||||
last_active: 500,
|
||||
tabs: vec![Tab::leaf(2)],
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
panes: vec![
|
||||
PaneRecord::new(1),
|
||||
PaneRecord {
|
||||
cwd: Some("/srv/checkout".into()),
|
||||
..PaneRecord::new(2)
|
||||
},
|
||||
],
|
||||
};
|
||||
let rows = rows_from_machine(&machine);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0].id, newer, "newest first");
|
||||
assert_eq!(rows[0].name, "web");
|
||||
assert_eq!(rows[1].id, older);
|
||||
}
|
||||
|
||||
/// A record with no user-set name falls back to the same derived name a
|
||||
/// local workspace would get, rather than showing a raw uuid.
|
||||
#[test]
|
||||
fn rows_derive_a_name_when_the_record_has_none() {
|
||||
let id = WorkspaceId::new();
|
||||
let list = vec![serde_json::json!({
|
||||
"id": id.to_string(),
|
||||
"session": {
|
||||
"tabs": [{
|
||||
"pane": { "Leaf": { "cwd": "/srv/checkout" } }
|
||||
}]
|
||||
},
|
||||
"last_active": 1,
|
||||
})];
|
||||
let rows = rows_from_list(&list);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].name, "checkout");
|
||||
assert_eq!(
|
||||
rows[0].name, "checkout",
|
||||
"no user name falls back to the first pane's directory"
|
||||
);
|
||||
assert_eq!(rows[0].panes, 1);
|
||||
assert_eq!(rows[1].name, "api", "a user-set name wins");
|
||||
}
|
||||
|
||||
fn host(label: &str, detail: &str) -> HostChoice {
|
||||
|
||||
+205
-427
@@ -1,7 +1,7 @@
|
||||
//! The window's half of "Connect to Host".
|
||||
//!
|
||||
//! [`ui::remote_connect`](crate::ui::remote_connect) is the plumbing — SSH
|
||||
//! specs, routed control connections, the remote workspace store. This is the
|
||||
//! specs, routed control connections, the remote machine's tree. This is the
|
||||
//! part that lives on a window: the state the home page renders, the steps that
|
||||
//! move between those states, and the guards that keep a window on one machine.
|
||||
//!
|
||||
@@ -19,7 +19,7 @@
|
||||
//! |---|---|
|
||||
//! | New tab / split | [`Tty7App::spawn_host`] — a remote window refuses to spawn a local shell |
|
||||
//! | Reopening a closed tab | [`Tty7App::rebind_host`] clears the closed stack when a window changes machine |
|
||||
//! | Restart / session restore | `WorkspaceStore::record`'s storage split — a remote entry never holds a local layout on disk |
|
||||
//! | Restart / session restore | the machine's own tree is the only layout source — the client persists no layout at all |
|
||||
//!
|
||||
//! The fourth path, dragging a tab between windows, does not exist in tty7:
|
||||
//! tabs never leave the window they were opened in, so there is nothing to
|
||||
@@ -661,10 +661,10 @@ impl Tty7App {
|
||||
rows: rows.clone(),
|
||||
},
|
||||
);
|
||||
remote_connect::RemoteConnections::insert(cx, connected.host, home.clone());
|
||||
remote_connect::HostLinks::insert(cx, connected.host, home.clone());
|
||||
self.prompt_remote_daemon_mismatch_later(cx);
|
||||
// Nothing left to *show* about the attempt: the machine is now
|
||||
// in `RemoteConnections` and its group in the switcher fills
|
||||
// in `HostLinks` and its group in the switcher fills
|
||||
// itself from there and from the snapshot above.
|
||||
self.connect = None;
|
||||
}
|
||||
@@ -688,7 +688,8 @@ impl Tty7App {
|
||||
) {
|
||||
let host = RemoteRef::new(target, row.id);
|
||||
let id = WorkspaceStore::claim_remote(cx, host);
|
||||
WorkspaceStore::apply_remote(cx, id, &row.record);
|
||||
// No record to apply: the machine's tree is pulled when the window
|
||||
// hydrates, which the enter below sets in motion.
|
||||
self.enter_remote_workspace(id, window, cx);
|
||||
}
|
||||
|
||||
@@ -711,7 +712,8 @@ impl Tty7App {
|
||||
// it from the tabs' repo/cwd, which is the same rule a local workspace
|
||||
// follows and the one intended. A workspace that opened in
|
||||
// `~` and then had a repo opened in it renames itself for free.
|
||||
self.push_remote_layout(id, cx);
|
||||
// The machine learns about the workspace when the window's hydration
|
||||
// finds nothing under this id and creates it (`WorkspaceCreate`).
|
||||
log::info!(
|
||||
"new remote workspace on {target} rooted at {}",
|
||||
home.display()
|
||||
@@ -746,44 +748,6 @@ impl Tty7App {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Push this window's workspace record to the machine that owns it.
|
||||
///
|
||||
/// The other half of the storage split: the client keeps `open`, the window
|
||||
/// geometry and the pointer; everything that is a fact about the machine
|
||||
/// goes over there. Fire-and-forget on a background task — a failed push is
|
||||
/// a log line, not a modal, because the record is rewritten on every
|
||||
/// structural change anyway.
|
||||
pub(crate) fn push_remote_layout(&self, id: WorkspaceId, cx: &mut gpui::App) {
|
||||
let Some((host, key, record)) = WorkspaceStore::remote_payload(cx, id) else {
|
||||
return;
|
||||
};
|
||||
let Some(connection) = remote_connect::RemoteConnections::get(cx, host.host_id()) else {
|
||||
// Not connected: the layout is pushed again when it is, and the
|
||||
// remote's own copy is still the last good one.
|
||||
return;
|
||||
};
|
||||
// Marked before the task starts and cleared when it lands, so a
|
||||
// `WorkspaceChanged` that arrives in between does not pull the record
|
||||
// this push is replacing back over the top of it.
|
||||
cx.default_global::<RemoteLinks>().pushing.insert(id);
|
||||
cx.spawn(async move |cx| {
|
||||
cx.background_executor()
|
||||
.spawn(async move {
|
||||
if let Err(e) = remote_connect::put_remote_layout(&connection, key, record) {
|
||||
log::warn!(
|
||||
"could not push the workspace layout to {}: {e}",
|
||||
host.target
|
||||
);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
cx.update(|cx| {
|
||||
cx.default_global::<RemoteLinks>().pushing.remove(&id);
|
||||
});
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// `open: true` remote workspaces reconnect at launch.
|
||||
///
|
||||
/// **M6 owns the behaviour**; this owns the seam. Startup opens a window per
|
||||
@@ -798,7 +762,7 @@ impl Tty7App {
|
||||
return;
|
||||
};
|
||||
remote_connect::register(cx);
|
||||
if remote_connect::RemoteConnections::get(cx, host.host_id()).is_some() {
|
||||
if remote_connect::HostLinks::get(cx, host.host_id()).is_some() {
|
||||
// Another window on the same machine got there first. One connection
|
||||
// per machine is the point — D7's "connect immediately" is about the
|
||||
// *machine*, and a second link to it would be a second SSH session
|
||||
@@ -1060,15 +1024,6 @@ pub(crate) fn pane_route_for(cx: &gpui::App, workspace: WorkspaceId) -> crate::t
|
||||
crate::terminal::PaneRoute::for_workspace(pane_workspace_for(cx, workspace).as_ref())
|
||||
}
|
||||
|
||||
/// The connection for a remote workspace, if this process has one.
|
||||
pub(crate) fn connection_for(
|
||||
cx: &mut gpui::App,
|
||||
workspace: WorkspaceId,
|
||||
) -> Option<Arc<RemoteHost>> {
|
||||
let host = WorkspaceStore::remote_ref(cx, workspace)?;
|
||||
remote_connect::RemoteConnections::get(cx, host.host_id())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The supervisor (the connection state machine, running)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1078,12 +1033,12 @@ pub(crate) fn connection_for(
|
||||
/// Fast enough that a `Preempted` push turns a window read-only while the user
|
||||
/// is still looking at the machine they typed on, slow enough to be free: a tick
|
||||
/// is a hash-map walk over the handful of machines a person has open.
|
||||
const PUMP_TICK: Duration = Duration::from_millis(250);
|
||||
pub(crate) const PUMP_TICK: Duration = Duration::from_millis(250);
|
||||
|
||||
/// One machine's link, as the supervisor sees it.
|
||||
///
|
||||
/// Per **machine**, not per workspace, because that is the granularity a
|
||||
/// connection actually has (`RemoteConnections` is keyed by [`HostId`], and two
|
||||
/// connection actually has (`HostLinks` is keyed by [`HostId`], and two
|
||||
/// windows on one box share a link). Preemption is the one thing that is
|
||||
/// per-workspace, and it is kept separately for exactly that reason.
|
||||
struct MachineLink {
|
||||
@@ -1116,6 +1071,13 @@ pub(crate) struct RemoteLinks {
|
||||
/// Workspaces taken over, and by whom. Per **workspace**: one machine can
|
||||
/// hold three of them and lose exactly one.
|
||||
preempted: std::collections::HashMap<WorkspaceId, String>,
|
||||
/// Workspaces being taken *back*: [`RemoteLinks::retry_now`] cleared their
|
||||
/// preemption and the reconnect is in flight. Remembered because the
|
||||
/// window still shows the pre-takeover layout, and [`finish_attempt`]
|
||||
/// must rebuild it from the tree whole (`Adopt::Replace`) — the IfEmpty
|
||||
/// hydration it runs for an ordinary reconnect skips any non-empty
|
||||
/// window, which is precisely what a preempted window is.
|
||||
reclaiming: std::collections::HashSet<WorkspaceId>,
|
||||
/// Machines the user has deliberately disconnected from.
|
||||
///
|
||||
/// Without this the supervisor would reconnect on the next tick: it keeps a
|
||||
@@ -1135,12 +1097,6 @@ pub(crate) struct RemoteLinks {
|
||||
/// absent from this map has never been seen before, which is **not** the
|
||||
/// same as having restarted — see [`finish_attempt`].
|
||||
instances: std::collections::HashMap<HostId, String>,
|
||||
/// Workspaces this client is pushing a layout for right now.
|
||||
///
|
||||
/// Read by [`refresh_remote_workspace`], which skips them: a record we are
|
||||
/// in the middle of replacing is not one to pull back over the top of
|
||||
/// ourselves.
|
||||
pushing: std::collections::HashSet<WorkspaceId>,
|
||||
/// The start-up sheet queue. Lives here because it is part of the
|
||||
/// same connection state and has to survive individual windows — the sheet
|
||||
/// belongs to a machine, not to whichever window happened to ask first.
|
||||
@@ -1162,6 +1118,18 @@ impl gpui::Global for RemoteLinks {}
|
||||
/// `remote_connect`'s install mailbox uses, for the identical reason.
|
||||
static EVENTS: Mutex<Vec<(HostId, ControlEvent)>> = Mutex::new(Vec::new());
|
||||
|
||||
/// Point the process-wide control-event observer at [`EVENTS`]. Idempotent
|
||||
/// (installing the same closure again is harmless), and shared with the local
|
||||
/// link's pump ([`crate::ui::local_link::LocalLink::install`]) — whichever
|
||||
/// comes up first, reader threads must never find nobody listening.
|
||||
pub(crate) fn install_event_observer() {
|
||||
crate::daemon::control::set_event_observer(Arc::new(|host, event| {
|
||||
if let Ok(mut queue) = EVENTS.lock() {
|
||||
queue.push((host, event));
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
impl RemoteLinks {
|
||||
/// Start the supervisor, and make sure control events have somewhere to go.
|
||||
///
|
||||
@@ -1169,11 +1137,7 @@ impl RemoteLinks {
|
||||
/// (the connect flow, opening one, start-up), because any of them can be the
|
||||
/// first.
|
||||
pub(crate) fn ensure_running(cx: &mut gpui::App) {
|
||||
crate::daemon::control::set_event_observer(Arc::new(|host, event| {
|
||||
if let Ok(mut queue) = EVENTS.lock() {
|
||||
queue.push((host, event));
|
||||
}
|
||||
}));
|
||||
install_event_observer();
|
||||
if cx.default_global::<RemoteLinks>().pumping {
|
||||
return;
|
||||
}
|
||||
@@ -1233,7 +1197,12 @@ impl RemoteLinks {
|
||||
return;
|
||||
};
|
||||
let links = cx.default_global::<RemoteLinks>();
|
||||
links.preempted.remove(&workspace);
|
||||
if links.preempted.remove(&workspace).is_some() {
|
||||
// Taking back, not merely reconnecting: the window's layout is
|
||||
// the pre-takeover one, so the attach that lands must rebuild it
|
||||
// from the tree rather than trust what it shows.
|
||||
links.reclaiming.insert(workspace);
|
||||
}
|
||||
// Asking to reconnect outranks having asked to disconnect.
|
||||
links.suspended.remove(&host.host_id());
|
||||
let link = links.machines.entry(host.host_id()).or_insert(MachineLink {
|
||||
@@ -1273,11 +1242,11 @@ impl RemoteLinks {
|
||||
// would keep reading from a socket that is about to be dropped under it.
|
||||
for (workspace, _) in workspaces_on(cx, host) {
|
||||
release_panes(cx, workspace);
|
||||
cx.default_global::<RemoteLinks>()
|
||||
.preempted
|
||||
.remove(&workspace);
|
||||
let links = cx.default_global::<RemoteLinks>();
|
||||
links.preempted.remove(&workspace);
|
||||
links.reclaiming.remove(&workspace);
|
||||
}
|
||||
remote_connect::RemoteConnections::remove(cx, host);
|
||||
remote_connect::HostLinks::remove(cx, host);
|
||||
cx.default_global::<RemoteLinks>().machines.remove(&host);
|
||||
log::info!("disconnected from a machine at the user's request");
|
||||
cx.refresh_windows();
|
||||
@@ -1317,6 +1286,7 @@ fn pump_tick(cx: &mut gpui::App) -> bool {
|
||||
let forgotten = links.machines.len();
|
||||
links.machines.clear();
|
||||
links.preempted.clear();
|
||||
links.reclaiming.clear();
|
||||
links.suspended.clear();
|
||||
// Logged because the *state* it leaves behind is indistinguishable from
|
||||
// never having connected: `status_of` reads a missing link as
|
||||
@@ -1338,8 +1308,8 @@ fn pump_tick(cx: &mut gpui::App) -> bool {
|
||||
if suspended.contains(&host) {
|
||||
continue;
|
||||
}
|
||||
let live = remote_connect::RemoteConnections::get(cx, host)
|
||||
.is_some_and(|h| h.client().is_connected());
|
||||
let live =
|
||||
remote_connect::HostLinks::get(cx, host).is_some_and(|h| h.client().is_connected());
|
||||
let attempting = cx
|
||||
.try_global::<RemoteLinks>()
|
||||
.and_then(|l| l.machines.get(&host))
|
||||
@@ -1356,6 +1326,9 @@ fn pump_tick(cx: &mut gpui::App) -> bool {
|
||||
if became {
|
||||
changed = true;
|
||||
log::info!("link to {target} is attached");
|
||||
// A fresh link means whatever the mirror held is history; the
|
||||
// full pull re-bases it before deltas resume advancing it.
|
||||
crate::ui::machine_mirror::MachineMirrors::refresh(cx, host);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1366,8 +1339,8 @@ fn pump_tick(cx: &mut gpui::App) -> bool {
|
||||
// The link is down. Drop the dead host object so nothing keeps calling
|
||||
// into it — a control connection that has gone is the whole
|
||||
// workspace's lifeline, not one failed request.
|
||||
if remote_connect::RemoteConnections::get(cx, host).is_some() {
|
||||
remote_connect::RemoteConnections::remove(cx, host);
|
||||
if remote_connect::HostLinks::get(cx, host).is_some() {
|
||||
remote_connect::HostLinks::remove(cx, host);
|
||||
log::info!("lost the control connection to {target}; reconnecting");
|
||||
}
|
||||
|
||||
@@ -1426,7 +1399,7 @@ fn prune_suspended(
|
||||
/// window that is not there.
|
||||
fn bound_machines(cx: &gpui::App) -> Vec<(HostId, RemoteTarget)> {
|
||||
let mut out: Vec<(HostId, RemoteTarget)> = Vec::new();
|
||||
for workspace in &WorkspaceStore::all(cx).workspaces {
|
||||
for workspace in &WorkspaceStore::all(cx).views {
|
||||
let Some(host) = workspace.host.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
@@ -1445,7 +1418,7 @@ fn bound_machines(cx: &gpui::App) -> Vec<(HostId, RemoteTarget)> {
|
||||
/// belong to.
|
||||
fn workspaces_on(cx: &gpui::App, host: HostId) -> Vec<(WorkspaceId, String)> {
|
||||
WorkspaceStore::all(cx)
|
||||
.workspaces
|
||||
.views
|
||||
.iter()
|
||||
.filter(|w| w.open)
|
||||
.filter_map(|w| {
|
||||
@@ -1456,14 +1429,11 @@ fn workspaces_on(cx: &gpui::App, host: HostId) -> Vec<(WorkspaceId, String)> {
|
||||
}
|
||||
|
||||
/// Apply everything the reader threads pushed since the last tick.
|
||||
fn drain_events(cx: &mut gpui::App) {
|
||||
pub(crate) fn drain_events(cx: &mut gpui::App) {
|
||||
let events = match EVENTS.lock() {
|
||||
Ok(mut queue) => std::mem::take(&mut *queue),
|
||||
Err(_) => return,
|
||||
};
|
||||
// Read out before the loop: the pull is one round trip per workspace no
|
||||
// matter how many events asked for it.
|
||||
let stale = stale_workspaces(&events);
|
||||
for (host, event) in events {
|
||||
match event {
|
||||
// The takeover, arriving. The window goes read-only and
|
||||
@@ -1481,102 +1451,41 @@ fn drain_events(cx: &mut gpui::App) {
|
||||
.preempted
|
||||
.insert(id, by.clone());
|
||||
release_panes(cx, id);
|
||||
// The window's tree-sync state goes with the streams: its
|
||||
// mirror and queue describe a session that just lost the
|
||||
// workspace, and its `informed` licence must not survive into
|
||||
// the take-back (see `tree_sync::on_preempted`).
|
||||
crate::ui::tree_sync::on_preempted(cx, id);
|
||||
cx.refresh_windows();
|
||||
}
|
||||
// Handled by `stale_workspaces` above, in one pull per workspace.
|
||||
ControlEvent::WorkspaceChanged { .. } => {}
|
||||
// Another writer edited a workspace tree this client shows: apply
|
||||
// the delta to the mirror and the live window (or re-pull the
|
||||
// workspace when it will not apply cleanly).
|
||||
ControlEvent::Layout { workspace, delta } => {
|
||||
crate::ui::tree_sync::on_layout_delta(cx, host, &workspace, delta);
|
||||
}
|
||||
// The machine dropped deltas for this connection: every mirror of
|
||||
// it is now wrong in a way no later delta repairs. Re-pull the
|
||||
// machine whole and rebuild the windows on it — the recovery an
|
||||
// unappliable delta already uses, here announced by the server
|
||||
// instead of stumbled into.
|
||||
ControlEvent::LayoutResync => {
|
||||
log::info!("{host:?} dropped layout deltas for this client; re-pulling");
|
||||
crate::ui::machine_mirror::MachineMirrors::refresh(cx, host);
|
||||
for (workspace, _) in crate::ui::windows::WindowRegistry::open_windows(cx) {
|
||||
if WorkspaceStore::host_of(cx, workspace) != host {
|
||||
continue;
|
||||
}
|
||||
// A preempted window stays passive; its take-back re-pulls.
|
||||
if workspace_is_preempted(cx, workspace) {
|
||||
continue;
|
||||
}
|
||||
crate::ui::tree_sync::resync_window_from_tree(cx, workspace);
|
||||
}
|
||||
}
|
||||
other => log::debug!("unhandled control event from {host:?}: {other:?}"),
|
||||
}
|
||||
}
|
||||
for (host, key) in stale {
|
||||
refresh_remote_workspace(cx, host, key);
|
||||
}
|
||||
}
|
||||
|
||||
/// The workspaces a batch of events says to re-read, each named once.
|
||||
///
|
||||
/// The machine's own record changed — another client of ours moved a tab,
|
||||
/// renamed a workspace, closed one. The event carries **no record**, only "go
|
||||
/// and read it again", and B3 is explicit that losing one of these is safe and
|
||||
/// getting two is safe. That is exactly the licence to collapse a burst into one
|
||||
/// round trip, and the reason nothing here tries to be incremental: there is no
|
||||
/// state to keep, so there is none to get wrong.
|
||||
fn stale_workspaces(events: &[(HostId, ControlEvent)]) -> Vec<(HostId, String)> {
|
||||
let mut out: Vec<(HostId, String)> = Vec::new();
|
||||
for (host, event) in events {
|
||||
if let ControlEvent::WorkspaceChanged { id } = event
|
||||
&& !out.iter().any(|(h, key)| h == host && key == id)
|
||||
{
|
||||
out.push((*host, id.clone()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Re-read one workspace's record from the machine that owns it and apply it.
|
||||
///
|
||||
/// # Why this cannot interrupt what the user is doing
|
||||
///
|
||||
/// It lands in the **store**, not in the window. `apply_remote` writes the three
|
||||
/// remote-owned fields of the client's `Workspace` entry (`name`, `session`,
|
||||
/// `last_active`) and nothing rebuilds a live window from that entry while the
|
||||
/// window is open — a workspace's tabs are built when the window opens or swaps
|
||||
/// workspaces, and `claimable_session` scrubs a remote entry's layout even then.
|
||||
/// So there is no path from here to a closed tab, a re-spawned pane or a moved
|
||||
/// focus; what a user sees change is the workspace's *name*.
|
||||
///
|
||||
/// That is deliberate rather than incidental. The remote is the
|
||||
/// authority for the layout, but the client that has the window open is the one
|
||||
/// *living* in it, and rearranging somebody's panes underneath them because
|
||||
/// another machine moved a tab is not a refresh, it is a fight. The remote's
|
||||
/// layout is what a window opens *from* — on the next connect, reconnect or
|
||||
/// reopen — and this keeps the copy it will open from current.
|
||||
///
|
||||
/// # The one race, and how it is settled
|
||||
///
|
||||
/// A push of ours can be in flight when an event arrives (the server excludes
|
||||
/// the writer, so the event is another client's, but it may describe a moment
|
||||
/// before our write). Applying it would briefly show that client's name for a
|
||||
/// workspace we are mid-rename of. So a workspace with a push in flight is
|
||||
/// skipped: our push is about to become the machine's truth, and B3's "dropping
|
||||
/// one is safe" is what makes skipping the right move rather than a queue.
|
||||
fn refresh_remote_workspace(cx: &mut gpui::App, host: HostId, store_key: String) {
|
||||
let Some(id) = client_id_for(cx, host, &store_key) else {
|
||||
// A workspace on that machine this client has no window on. Nothing to
|
||||
// refresh; the record is pulled when it is opened.
|
||||
log::debug!("remote workspace {store_key} changed on a machine with no window here");
|
||||
return;
|
||||
};
|
||||
if cx.default_global::<RemoteLinks>().pushing.contains(&id) {
|
||||
log::debug!("skipping the refresh of {id}: this client is mid-push for it");
|
||||
return;
|
||||
}
|
||||
let Some(connection) = remote_connect::RemoteConnections::get(cx, host) else {
|
||||
// Not connected: the next connect pulls the whole list anyway.
|
||||
return;
|
||||
};
|
||||
cx.spawn(async move |cx| {
|
||||
let pulled = cx
|
||||
.background_executor()
|
||||
.spawn(async move { remote_connect::get_remote_layout(&connection, store_key) })
|
||||
.await;
|
||||
match pulled {
|
||||
Ok(record) => {
|
||||
cx.update(|cx| {
|
||||
WorkspaceStore::apply_remote(cx, id, &record);
|
||||
cx.refresh_windows();
|
||||
});
|
||||
}
|
||||
// The workspace was deleted on the far side. The window stays open
|
||||
// with what it had — a window is never closed, and least of all
|
||||
// because another machine decided this one was done with it.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
log::info!("remote workspace {id} is gone from its machine; keeping the window");
|
||||
}
|
||||
Err(e) => log::warn!("could not re-read remote workspace {id}: {e}"),
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Come back to a machine whose server was just replaced.
|
||||
@@ -1601,7 +1510,7 @@ fn reconnect_after_restart(origin: &str, cx: &mut gpui::App) {
|
||||
let Some(host) = remote_connect::origin_host(origin) else {
|
||||
return;
|
||||
};
|
||||
remote_connect::RemoteConnections::remove(cx, host);
|
||||
remote_connect::HostLinks::remove(cx, host);
|
||||
for (workspace, _) in workspaces_on(cx, host) {
|
||||
RemoteLinks::retry_now(cx, workspace);
|
||||
}
|
||||
@@ -1669,7 +1578,7 @@ fn launch_attempt(cx: &mut gpui::App, host: HostId, target: RemoteTarget) {
|
||||
log::info!("took workspace {key} back from {who}");
|
||||
}
|
||||
Ok(_) => {}
|
||||
// A machine that has no workspace store (an older
|
||||
// A machine that has no machine tree (an older
|
||||
// server) still serves files; the workspace is usable,
|
||||
// it simply cannot be claimed exclusively.
|
||||
Err(e) => log::warn!("could not attach to workspace {key}: {e}"),
|
||||
@@ -1692,43 +1601,39 @@ fn finish_attempt(
|
||||
) {
|
||||
match outcome {
|
||||
Ok(connected) => {
|
||||
// The remote's record is the authority for the layout,
|
||||
// so what came back with the connect replaces what this client had.
|
||||
let rows = connected.rows.clone();
|
||||
let instance = connected.host.peer().instance.clone();
|
||||
let restarted = server_restarted(cx, host, &connected.host);
|
||||
// The home too, not just the connection: this is the path a machine
|
||||
// comes back on after a restart or a dropped link, and dropping it
|
||||
// here is what left "New Workspace" missing on a machine the panel
|
||||
// was quite happily calling connected.
|
||||
remote_connect::RemoteConnections::insert(cx, connected.host, connected.home);
|
||||
for (id, key) in workspaces_on(cx, host) {
|
||||
if let Some(row) = rows.iter().find(|r| r.id.to_string() == key) {
|
||||
WorkspaceStore::apply_remote(cx, id, &row.record);
|
||||
}
|
||||
cx.default_global::<RemoteLinks>().preempted.remove(&id);
|
||||
// The same question `restarted` answers, asked of the *record*
|
||||
// rather than of this process's memory — and it is the only one
|
||||
// that can answer across a client restart. `instances` is an
|
||||
// in-memory map, so on a cold launch every machine is a first
|
||||
// sighting and `restarted` is false; a server replaced while
|
||||
// this client was closed would sail through, and its recycled
|
||||
// ids would attach to whatever unrelated shells now hold the
|
||||
// numbers. `daemon_instance` is on disk and remembers.
|
||||
let stale = WorkspaceStore::forget_stale_pane_ids(cx, id, &instance);
|
||||
if restarted || stale {
|
||||
// Every pane id this workspace holds was minted by a process
|
||||
// that is gone. Re-attaching them would cost one doomed round
|
||||
// trip each and leave the window exactly as disconnected as
|
||||
// it is now, so the window is rebuilt from the layout instead
|
||||
// — the same thing a local daemon restart does.
|
||||
rebuild_after_server_restart(cx, id);
|
||||
remote_connect::HostLinks::insert(cx, connected.host, connected.home);
|
||||
for (id, _key) in workspaces_on(cx, host) {
|
||||
let reclaimed = {
|
||||
let links = cx.default_global::<RemoteLinks>();
|
||||
// The attach that just landed preempts whoever held the
|
||||
// workspace, so a still-recorded preemption is one this
|
||||
// reconnect ends — same situation as an explicit Take
|
||||
// Back, and rebuilt the same way below.
|
||||
links.preempted.remove(&id).is_some() | links.reclaiming.remove(&id)
|
||||
};
|
||||
if restarted || reclaimed {
|
||||
// Rebuild from the tree whole. After a server restart
|
||||
// every pane this window shows lived in a process that is
|
||||
// gone (a fresh server holds no live panes), so the tree
|
||||
// lowers each leaf to a revival — fresh shells in the
|
||||
// recorded cwds, agents resumed. After a take-back the
|
||||
// panes may well be alive, but the *layout* on screen is
|
||||
// the pre-takeover one: the IfEmpty hydration below would
|
||||
// skip this non-empty window and leave it stale — the
|
||||
// "take back re-pulls whole" the preemption paths promise
|
||||
// happens here, as a Replace.
|
||||
crate::ui::tree_sync::resync_window_from_tree(cx, id);
|
||||
} else {
|
||||
relink_panes(cx, id);
|
||||
// A window that came up before its machine did has no panes to
|
||||
// relink — it opened empty because there was nothing to route
|
||||
// to. Now there is.
|
||||
hydrate_window(cx, id);
|
||||
// A window that came up before its machine did has no panes
|
||||
// to relink — it opened empty because there was nothing to
|
||||
// route to. Now there is: fill it from the tree.
|
||||
crate::ui::tree_sync::hydrate_window_from_tree(cx, id);
|
||||
}
|
||||
// Same reason the window had no panes: with the machine
|
||||
// unreachable there was nothing to ask for its shells, so the
|
||||
@@ -1824,55 +1729,6 @@ fn relink_panes(cx: &mut gpui::App, workspace: WorkspaceId) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the tabs of a window that opened before its machine was reachable.
|
||||
///
|
||||
/// This is the other end of [`crate::core::session::WorkspaceStore::claim`]'s
|
||||
/// reachability rule. A remote workspace reopened at launch has nowhere to route
|
||||
/// to yet — the link is still being built — so it opens empty rather than
|
||||
/// spawning a second set of shells beside the ones still running over there.
|
||||
/// The layout it *would* have opened from is the entry's cached session, which
|
||||
/// [`finish_attempt`] has just refreshed from the machine itself, so by the time
|
||||
/// this runs the window is rebuilding from the authority.
|
||||
///
|
||||
/// # What it will not do
|
||||
///
|
||||
/// **Only an empty window is touched.** A window with tabs is one the user is
|
||||
/// working in; rearranging it because a link came back is the same fight
|
||||
/// [`refresh_remote_workspace`] refuses to pick. That also makes this safe to
|
||||
/// call on every reconnect — the second one through finds tabs and leaves.
|
||||
fn hydrate_window(cx: &mut gpui::App, workspace: WorkspaceId) {
|
||||
let session = match WorkspaceStore::all(cx).get(workspace) {
|
||||
Some(entry) if entry.is_remote() => entry.session.clone(),
|
||||
// Local, or an entry that went away while the connect was in flight.
|
||||
_ => return,
|
||||
};
|
||||
if session.tabs.is_empty() {
|
||||
// Nothing to restore: a workspace that was quit from the home page, or
|
||||
// a brand-new one. Its window is right as it is.
|
||||
return;
|
||||
}
|
||||
let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, workspace) else {
|
||||
return;
|
||||
};
|
||||
let Some(app) =
|
||||
crate::ui::windows::WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if !app.read(cx).tabs.is_empty() {
|
||||
return;
|
||||
}
|
||||
log::info!(
|
||||
"rebuilding {} tab(s) of workspace {workspace} now its machine is reachable",
|
||||
session.tabs.len()
|
||||
);
|
||||
let _ = handle.update(cx, move |_, window, cx| {
|
||||
app.update(cx, |app, cx| {
|
||||
app.adopt_workspace(workspace, session, window, cx)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the machine we just reconnected to is being served by a *different*
|
||||
/// `tty7-server` process than the one we last spoke to.
|
||||
///
|
||||
@@ -1922,55 +1778,6 @@ fn note_instance(
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild a workspace's window after its machine's server was replaced.
|
||||
///
|
||||
/// The local analogue is [`Tty7App::restart_daemon_confirmed`], and this is
|
||||
/// deliberately the same shape: the layout is the thing that survives, and every
|
||||
/// leaf in it comes back as a fresh shell in its saved cwd. What makes it safe
|
||||
/// here is only that [`server_restarted`] *knew* — the same rebuild triggered by
|
||||
/// a guess would be a way to lose running work.
|
||||
///
|
||||
/// **The saved pane ids are dropped first**, and that is what makes the resume
|
||||
/// work rather than being a tidiness measure. `session_to_pane` keeps a remote
|
||||
/// leaf's id unconditionally (its liveness cannot be probed without a round
|
||||
/// trip) and lets the attach fail into a spawn *inside* the terminal — by which
|
||||
/// point the code that would have sent `claude --resume <id>` has already
|
||||
/// decided it wasn't needed. Clearing the ids up here makes the leaf take the
|
||||
/// same path a dead local pane takes, so the agent conversation continues.
|
||||
///
|
||||
/// Unlike [`hydrate_window`] this does **not** skip a window with tabs. Those
|
||||
/// tabs are precisely what has to go: every one of them is a pane bound to a
|
||||
/// process that no longer exists.
|
||||
fn rebuild_after_server_restart(cx: &mut gpui::App, workspace: WorkspaceId) {
|
||||
let mut session = match WorkspaceStore::all(cx).get(workspace) {
|
||||
Some(entry) if entry.is_remote() => entry.session.clone(),
|
||||
_ => return,
|
||||
};
|
||||
if session.tabs.is_empty() {
|
||||
return;
|
||||
}
|
||||
for tab in &mut session.tabs {
|
||||
tty7_core::core::session::blank_pane_ids(&mut tab.pane);
|
||||
}
|
||||
let Some(handle) = crate::ui::windows::WindowRegistry::window_for(cx, workspace) else {
|
||||
return;
|
||||
};
|
||||
let Some(app) =
|
||||
crate::ui::windows::WindowRegistry::app_for(cx, workspace).and_then(|app| app.upgrade())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
log::info!(
|
||||
"rebuilding {} tab(s) of workspace {workspace}: its machine is serving a new process",
|
||||
session.tabs.len()
|
||||
);
|
||||
let _ = handle.update(cx, move |_, window, cx| {
|
||||
app.update(cx, |app, cx| {
|
||||
app.adopt_workspace(workspace, session, window, cx)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Ask the window showing `workspace` to refill its "+" dropdown, now that its
|
||||
/// machine is answering. No-op for a workspace with no window on screen.
|
||||
fn refresh_window_shells(cx: &mut gpui::App, workspace: WorkspaceId) {
|
||||
@@ -2146,10 +1953,101 @@ pub(crate) fn workspace_accepts_input(cx: &gpui::App, workspace: WorkspaceId) ->
|
||||
RemoteLinks::status_of(cx, workspace).is_none_or(|s| s.accepts_input())
|
||||
}
|
||||
|
||||
/// Whether another client's session currently holds `workspace`. Read by the
|
||||
/// delta application, which must leave a preempted window passive — attaching
|
||||
/// to the usurper's panes would steal the streams they are typing into.
|
||||
pub(crate) fn workspace_is_preempted(cx: &gpui::App, workspace: WorkspaceId) -> bool {
|
||||
cx.try_global::<RemoteLinks>()
|
||||
.is_some_and(|links| links.preempted.contains_key(&workspace))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Take Back is `retry_now` on a preempted workspace, and the window it
|
||||
/// recovers still shows the pre-takeover layout — so clearing the
|
||||
/// preemption must leave a `reclaiming` mark behind for `finish_attempt`
|
||||
/// to read, or the landed attach runs its ordinary IfEmpty hydration,
|
||||
/// skips the non-empty window, and the stale layout survives to roll the
|
||||
/// other client's edits back on the next save.
|
||||
#[gpui::test]
|
||||
fn taking_back_marks_the_workspace_for_a_whole_rebuild(cx: &mut gpui::TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
// `retry_now` wakes the supervisor, whose first tick resolves the
|
||||
// machine's route off the config global.
|
||||
cx.set_global(crate::core::config::Config::default());
|
||||
let host = RemoteRef::new(
|
||||
RemoteTarget::Alias {
|
||||
alias: "build-box".into(),
|
||||
},
|
||||
WorkspaceId::new(),
|
||||
);
|
||||
let view = crate::core::session::WindowView {
|
||||
host: Some(host),
|
||||
..Default::default()
|
||||
};
|
||||
let id = view.id;
|
||||
crate::core::session::WorkspaceStore::install_for_test(
|
||||
cx,
|
||||
crate::core::session::WindowViews {
|
||||
views: vec![view],
|
||||
active: None,
|
||||
},
|
||||
);
|
||||
cx.default_global::<RemoteLinks>()
|
||||
.preempted
|
||||
.insert(id, "laptop".into());
|
||||
|
||||
RemoteLinks::retry_now(cx, id);
|
||||
|
||||
let links = cx.default_global::<RemoteLinks>();
|
||||
assert!(
|
||||
!links.preempted.contains_key(&id),
|
||||
"the takeover is being reversed; the read-only state ends now"
|
||||
);
|
||||
assert!(
|
||||
links.reclaiming.contains(&id),
|
||||
"the attach that lands must know to rebuild this window from the tree"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A plain reconnect (never preempted) must not be marked for a rebuild —
|
||||
/// its panes are alive and re-attachable, and a Replace would tear down
|
||||
/// views the relink was about to reuse.
|
||||
#[gpui::test]
|
||||
fn a_plain_reconnect_is_not_marked_for_a_rebuild(cx: &mut gpui::TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
cx.set_global(crate::core::config::Config::default());
|
||||
let host = RemoteRef::new(
|
||||
RemoteTarget::Alias {
|
||||
alias: "build-box".into(),
|
||||
},
|
||||
WorkspaceId::new(),
|
||||
);
|
||||
let view = crate::core::session::WindowView {
|
||||
host: Some(host),
|
||||
..Default::default()
|
||||
};
|
||||
let id = view.id;
|
||||
crate::core::session::WorkspaceStore::install_for_test(
|
||||
cx,
|
||||
crate::core::session::WindowViews {
|
||||
views: vec![view],
|
||||
active: None,
|
||||
},
|
||||
);
|
||||
|
||||
RemoteLinks::retry_now(cx, id);
|
||||
|
||||
assert!(
|
||||
!cx.default_global::<RemoteLinks>().reclaiming.contains(&id),
|
||||
"nothing was taken over, so nothing needs the Replace path"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_status_strip_speaks_unless_everything_is_working() {
|
||||
assert_eq!(RemoteStatus::Attached.strip_message("build-box"), None);
|
||||
@@ -2263,52 +2161,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Every leaf loses its id, at every depth. A `Split` branch that kept its
|
||||
/// ids would leave those panes attaching to a dead process — and, worse,
|
||||
/// skipping the agent resume, because that only fires for a leaf with no id.
|
||||
#[test]
|
||||
fn forgetting_pane_ids_reaches_every_leaf() {
|
||||
use crate::core::session::{SessionAxis, SessionPane};
|
||||
|
||||
fn leaf(id: u64) -> SessionPane {
|
||||
SessionPane::Leaf {
|
||||
cwd: None,
|
||||
pane_id: Some(id),
|
||||
ssh_spec: None,
|
||||
agent: None,
|
||||
agent_session_id: None,
|
||||
agent_launch_argv: None,
|
||||
}
|
||||
}
|
||||
fn ids(pane: &SessionPane, out: &mut Vec<Option<u64>>) {
|
||||
match pane {
|
||||
SessionPane::Leaf { pane_id, .. } => out.push(*pane_id),
|
||||
SessionPane::Split { a, b, .. } => {
|
||||
ids(a, out);
|
||||
ids(b, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut pane = SessionPane::Split {
|
||||
axis: SessionAxis::Horizontal,
|
||||
ratio: 0.5,
|
||||
a: Box::new(leaf(1)),
|
||||
b: Box::new(SessionPane::Split {
|
||||
axis: SessionAxis::Vertical,
|
||||
ratio: 0.5,
|
||||
a: Box::new(leaf(2)),
|
||||
b: Box::new(leaf(3)),
|
||||
}),
|
||||
};
|
||||
let forgotten = tty7_core::core::session::blank_pane_ids(&mut pane);
|
||||
|
||||
let mut found = Vec::new();
|
||||
ids(&pane, &mut found);
|
||||
assert_eq!(found, vec![None, None, None]);
|
||||
assert_eq!(forgotten, 3, "every dropped claim is counted");
|
||||
}
|
||||
|
||||
// ── The reconnect schedule (no network) ─────────────────────────────────
|
||||
|
||||
/// The schedule is fixed: **1/2/4/…/30s capped, retried for ever**.
|
||||
@@ -2429,82 +2281,8 @@ mod tests {
|
||||
assert_eq!(q.waiting(), 1);
|
||||
}
|
||||
|
||||
// ── `WorkspaceChanged` → re-read (B3's push, arriving) ───────────────────
|
||||
// ── The input gate ───────────────────────────────────────────────────────
|
||||
|
||||
/// **A burst of changes costs one round trip per workspace.**
|
||||
///
|
||||
/// B3's contract for this event is that it means only "read it again", so
|
||||
/// losing one is safe and getting ten is safe. Collapsing them is the whole
|
||||
/// of the logic that rule buys — and the thing that keeps a client with a
|
||||
/// chatty peer from opening a `WorkspaceGet` per keystroke of theirs.
|
||||
#[test]
|
||||
fn a_burst_of_changes_asks_for_each_workspace_once() {
|
||||
let (a, b) = (host("ssh-alias:a"), host("ssh-alias:b"));
|
||||
let changed = |id: &str| ControlEvent::WorkspaceChanged { id: id.into() };
|
||||
let events = vec![
|
||||
(a, changed("w1")),
|
||||
(a, changed("w1")),
|
||||
(b, changed("w1")),
|
||||
(a, changed("w2")),
|
||||
(a, changed("w1")),
|
||||
];
|
||||
assert_eq!(
|
||||
stale_workspaces(&events),
|
||||
vec![
|
||||
(a, "w1".to_string()),
|
||||
(b, "w1".to_string()),
|
||||
(a, "w2".to_string()),
|
||||
],
|
||||
"one pull per (machine, workspace), in the order they were heard"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same workspace id on two machines is two workspaces — pane ids and
|
||||
/// store keys are per machine, and merging them would refresh one window
|
||||
/// from another box's record.
|
||||
#[test]
|
||||
fn a_change_is_scoped_to_the_machine_that_reported_it() {
|
||||
let (a, b) = (host("ssh-alias:a"), host("ssh-alias:b"));
|
||||
let events = vec![
|
||||
(a, ControlEvent::WorkspaceChanged { id: "same".into() }),
|
||||
(b, ControlEvent::WorkspaceChanged { id: "same".into() }),
|
||||
];
|
||||
assert_eq!(stale_workspaces(&events).len(), 2);
|
||||
}
|
||||
|
||||
/// Every other event is somebody else's business. A takeover in particular
|
||||
/// must not also trigger a pull: it is handled on its own path, and the
|
||||
/// record has not changed.
|
||||
#[test]
|
||||
fn only_a_workspace_change_asks_for_a_re_read() {
|
||||
let a = host("ssh-alias:a");
|
||||
let events = vec![
|
||||
(
|
||||
a,
|
||||
ControlEvent::Preempted {
|
||||
workspace: "w1".into(),
|
||||
by: "desktop".into(),
|
||||
},
|
||||
),
|
||||
(
|
||||
a,
|
||||
ControlEvent::PaneExited {
|
||||
pane_id: 3,
|
||||
code: None,
|
||||
},
|
||||
),
|
||||
];
|
||||
assert!(stale_workspaces(&events).is_empty());
|
||||
}
|
||||
|
||||
// ── The read-only degrade, state by state ───────────────────
|
||||
|
||||
/// The degrade in one table: which states are read-only, what the
|
||||
/// bottom line says, and what the strip offers to do about it.
|
||||
///
|
||||
/// `Preempted` reads differently on purpose — "not connected" would be a
|
||||
/// lie, because the link is usually fine and the workspace is simply
|
||||
/// somebody else's now.
|
||||
#[test]
|
||||
fn every_state_says_what_it_means_for_the_keyboard() {
|
||||
let cases = [
|
||||
@@ -2625,7 +2403,7 @@ mod tests {
|
||||
crate::ui::windows::WindowRegistry::init(cx);
|
||||
|
||||
let (host, target) = machine("build-box");
|
||||
let mut entry = crate::core::session::Workspace::on_remote(RemoteRef::new(
|
||||
let mut entry = crate::core::session::WindowView::on_remote(RemoteRef::new(
|
||||
target,
|
||||
WorkspaceId::new(),
|
||||
));
|
||||
@@ -2633,8 +2411,8 @@ mod tests {
|
||||
let id = entry.id;
|
||||
WorkspaceStore::install_for_test(
|
||||
cx,
|
||||
crate::core::session::Workspaces {
|
||||
workspaces: vec![entry],
|
||||
crate::core::session::WindowViews {
|
||||
views: vec![entry],
|
||||
active: None,
|
||||
},
|
||||
);
|
||||
|
||||
+22
-21
@@ -25,13 +25,14 @@
|
||||
//!
|
||||
//! # Where the rows come from
|
||||
//!
|
||||
//! `session.json` already records remote workspaces (`Workspace::host`), so a
|
||||
//! The view store records remote workspaces (`WindowView::host`), so a
|
||||
//! machine's workspaces are listed **without connecting to it** — the client
|
||||
//! remembers what it saw last time. Connecting only ever *adds*: the remote's
|
||||
//! own store is the authority, so its rows are merged in when a link exists and
|
||||
//! anything this client had not heard of shows up then (see [`Group::merge`]).
|
||||
//! That is what makes "expand a machine" a lazy, cheap gesture rather than a
|
||||
//! wizard.
|
||||
//! remembers which ones it saw, and their display facts come from the
|
||||
//! machine's mirror (`ui::machine_mirror`). Connecting only ever *adds*: the
|
||||
//! remote's own tree is the authority, so its rows are merged in when a link
|
||||
//! exists and anything this client had not heard of shows up then (see
|
||||
//! [`Group::merge`]). That is what makes "expand a machine" a lazy, cheap
|
||||
//! gesture rather than a wizard.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
@@ -194,7 +195,7 @@ struct Row {
|
||||
current: bool,
|
||||
/// Set for a workspace that exists on the remote but has no local record
|
||||
/// yet: opening it has to claim it first. `None` once it is in
|
||||
/// `session.json` like any other.
|
||||
/// the view store like any other.
|
||||
adopt: Option<Box<RemoteWorkspaceRow>>,
|
||||
/// This row's id **on its own machine**, for a remote workspace. It is what
|
||||
/// the remote's list is matched against — the local [`WorkspaceId`] above is
|
||||
@@ -217,7 +218,7 @@ pub(crate) struct HostSnapshot {
|
||||
/// it could never be given a group to appear in.
|
||||
pub target: RemoteTarget,
|
||||
/// What the remote said it had. The machine's `$HOME` is deliberately *not*
|
||||
/// here — it lives in `RemoteConnections`, app-wide, because every window
|
||||
/// here — it lives in `HostLinks`, app-wide, because every window
|
||||
/// needs it and only one of them ever did the connecting.
|
||||
pub rows: Vec<RemoteWorkspaceRow>,
|
||||
}
|
||||
@@ -317,7 +318,7 @@ impl Tty7App {
|
||||
{
|
||||
let app: &App = cx;
|
||||
let store = WorkspaceStore::all(app);
|
||||
for w in &store.workspaces {
|
||||
for w in &store.views {
|
||||
let (key, label, target) = match w.host.as_ref() {
|
||||
None => (String::new(), "This Computer".to_string(), None),
|
||||
Some(r) => {
|
||||
@@ -341,11 +342,14 @@ impl Tty7App {
|
||||
});
|
||||
groups[slot].rows.push(Row {
|
||||
id: w.id,
|
||||
name: w.display_name(),
|
||||
path: w
|
||||
.dominant_repo()
|
||||
.or_else(|| w.first_cwd())
|
||||
.map(|p| crate::ui::home::display_path(&p))
|
||||
// Both read the machine's mirror — the tree owns the
|
||||
// layout these used to be derived from. A machine not
|
||||
// pulled yet (launch's first frames; an unreached remote)
|
||||
// renders the not-knowing rather than a stale guess.
|
||||
name: crate::ui::machine_mirror::display_name(app, w)
|
||||
.unwrap_or_else(|| "Untitled".to_string()),
|
||||
path: crate::ui::machine_mirror::subject_path(app, w)
|
||||
.map(|p| crate::ui::home::display_path(std::path::Path::new(&p)))
|
||||
.unwrap_or_default(),
|
||||
when: crate::ui::home::relative_time(now, w.last_active),
|
||||
live: crate::terminal::pane_liveness::liveness_of(app, w),
|
||||
@@ -450,7 +454,7 @@ impl Tty7App {
|
||||
// connect, and every reconnect, records the machine's `$HOME` — and
|
||||
// that row is the only way to make a workspace on a machine, so it
|
||||
// has no business depending on which window did the connecting.
|
||||
group.home = remote_connect::RemoteConnections::home(cx, id);
|
||||
group.home = remote_connect::HostLinks::home(cx, id);
|
||||
if let Some(snapshot) = self.host_snapshots.get(&id) {
|
||||
group.merge(&snapshot.rows, now);
|
||||
}
|
||||
@@ -485,7 +489,7 @@ impl Tty7App {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match remote_connect::RemoteConnections::get(cx, target.host_id()) {
|
||||
match remote_connect::HostLinks::get(cx, target.host_id()) {
|
||||
Some(_) => Link::Connected,
|
||||
None => Link::Offline,
|
||||
}
|
||||
@@ -558,10 +562,7 @@ impl Tty7App {
|
||||
/// window's *current* workspace, because the field it opens is the chip. A
|
||||
/// list needs to rename the row that was aimed at, so it gets its own.
|
||||
fn switcher_rename(&mut self, id: WorkspaceId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let current = WorkspaceStore::all(cx)
|
||||
.get(id)
|
||||
.map(|w| w.display_name())
|
||||
.unwrap_or_default();
|
||||
let current = crate::ui::machine_mirror::display_name_for(cx, id).unwrap_or_default();
|
||||
let input = cx.new(|cx| InputState::new(window, cx).default_value(current));
|
||||
input.update(cx, |state, cx| state.focus(window, cx));
|
||||
let sub = cx.subscribe_in(
|
||||
@@ -586,7 +587,7 @@ impl Tty7App {
|
||||
return;
|
||||
};
|
||||
let value = input.read(cx).value().trim().to_string();
|
||||
WorkspaceStore::rename(cx, id, (!value.is_empty()).then_some(value));
|
||||
crate::ui::tree_sync::rename_workspace(cx, id, (!value.is_empty()).then_some(value));
|
||||
crate::ui::windows::refresh_menu(cx);
|
||||
if id == self.workspace {
|
||||
self.sync_window_title(window, cx);
|
||||
|
||||
+1
-3
@@ -442,9 +442,7 @@ impl Tty7App {
|
||||
// sweep is rate-limited, and past that gate each machine is only asked
|
||||
// once its own TTL has run out.
|
||||
crate::terminal::pane_liveness::sweep(cx);
|
||||
let current = crate::core::session::WorkspaceStore::all(cx)
|
||||
.get(self.workspace)
|
||||
.map(|w| w.display_name())
|
||||
let current = crate::ui::machine_mirror::display_name_for(cx, self.workspace)
|
||||
.unwrap_or_else(|| "tty7".to_string());
|
||||
// First character, uppercased — the whole point is a glyph that is
|
||||
// recognisably *this* workspace at a glance across windows.
|
||||
|
||||
+7
-2
@@ -198,14 +198,19 @@ fn window_menu_items(cx: &App) -> Vec<MenuItem> {
|
||||
items.push(MenuItem::Separator);
|
||||
}
|
||||
}
|
||||
// From the machine's mirror — the tree owns the layout the name is
|
||||
// derived from. Before the first pull lands the entry reads as the
|
||||
// shared fallback; the menu is rebuilt on every roster change anyway.
|
||||
let name = crate::ui::machine_mirror::display_name(cx, workspace)
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
let label = if *open {
|
||||
workspace.display_name()
|
||||
name
|
||||
} else {
|
||||
// The age is the useful discriminator among detached ones — several
|
||||
// may share a repo name.
|
||||
format!(
|
||||
"{} — {}",
|
||||
workspace.display_name(),
|
||||
name,
|
||||
crate::ui::home::relative_time(now, workspace.last_active)
|
||||
)
|
||||
};
|
||||
|
||||
+2967
File diff suppressed because it is too large
Load Diff
+115
-107
@@ -97,6 +97,22 @@ impl WindowRegistry {
|
||||
.or_else(|| registry.windows.first().map(|w| w.workspace))
|
||||
}
|
||||
|
||||
/// The `Tty7App` rendered in `window`, if it is one of ours.
|
||||
///
|
||||
/// For code that runs *inside* a window (an element's event handler) but
|
||||
/// has no line to the app entity — the inverse lookup of
|
||||
/// [`window_for`](Self::window_for), keyed by the handle instead of the
|
||||
/// workspace.
|
||||
pub fn app_in(cx: &mut App, window: &Window) -> Option<gpui::Entity<Tty7App>> {
|
||||
Self::sweep(cx);
|
||||
let handle = window.window_handle();
|
||||
cx.global::<Self>()
|
||||
.windows
|
||||
.iter()
|
||||
.find(|w| w.handle == handle)
|
||||
.and_then(|w| w.app.upgrade())
|
||||
}
|
||||
|
||||
/// The `Tty7App` showing `workspace`, if one is open.
|
||||
pub fn app_for(cx: &mut App, workspace: WorkspaceId) -> Option<WeakEntity<Tty7App>> {
|
||||
Self::sweep(cx);
|
||||
@@ -163,8 +179,8 @@ impl WindowRegistry {
|
||||
}
|
||||
|
||||
/// What a *brand-new* workspace's window starts with. Only consulted when the
|
||||
/// window is opening on a freshly minted workspace — one restored from
|
||||
/// `session.json` always rebuilds its saved tabs.
|
||||
/// window is opening on a freshly minted workspace — a known one opens empty
|
||||
/// and is filled from its machine's tree.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FreshStart {
|
||||
/// A single default terminal, the way every previous launch of tty7 came
|
||||
@@ -255,8 +271,8 @@ pub const MENU_SLOTS: usize = 9;
|
||||
/// visible *somewhere* or it may as well have been deleted.
|
||||
pub fn menu_order(cx: &App) -> Vec<(WorkspaceId, bool)> {
|
||||
let all = WorkspaceStore::all(cx);
|
||||
let mut open: Vec<_> = all.workspaces.iter().filter(|w| w.open).collect();
|
||||
let mut closed: Vec<_> = all.workspaces.iter().filter(|w| !w.open).collect();
|
||||
let mut open: Vec<_> = all.views.iter().filter(|w| w.open).collect();
|
||||
let mut closed: Vec<_> = all.views.iter().filter(|w| !w.open).collect();
|
||||
open.sort_by(|a, b| b.last_active.cmp(&a.last_active));
|
||||
closed.sort_by(|a, b| b.last_active.cmp(&a.last_active));
|
||||
open.into_iter()
|
||||
@@ -288,6 +304,11 @@ pub struct PaneCountQuery {
|
||||
}
|
||||
|
||||
/// Read the inputs for [`live_pane_count`]. Cheap; UI thread only.
|
||||
///
|
||||
/// `None` when the workspace's machine has never been pulled this session —
|
||||
/// the ids to count live only in its tree, and a prompt about to state "N
|
||||
/// running sessions will be ended" must say it could not ask rather than
|
||||
/// count against a guess.
|
||||
pub fn pane_count_query(cx: &App, workspace: WorkspaceId) -> Option<PaneCountQuery> {
|
||||
let ws = WorkspaceStore::all(cx).get(workspace)?;
|
||||
Some(PaneCountQuery {
|
||||
@@ -296,7 +317,7 @@ pub fn pane_count_query(cx: &App, workspace: WorkspaceId) -> Option<PaneCountQue
|
||||
// whichever *local* panes happen to hold those numbers and put a "3
|
||||
// running sessions will be ended" warning on a workspace that has none.
|
||||
route: crate::ui::remote_workspace::pane_route_for(cx, workspace),
|
||||
claimed: ws.pane_ids(),
|
||||
claimed: crate::ui::machine_mirror::pane_ids(cx, ws)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -390,9 +411,7 @@ fn confirm_destructive(
|
||||
verb: &'static str,
|
||||
act: fn(&mut App, WorkspaceId),
|
||||
) {
|
||||
let name = WorkspaceStore::all(cx)
|
||||
.get(workspace)
|
||||
.map(|w| w.display_name())
|
||||
let name = crate::ui::machine_mirror::display_name_for(cx, workspace)
|
||||
.unwrap_or_else(|| "this workspace".to_string());
|
||||
let query = pane_count_query(cx, workspace);
|
||||
let handle = window.window_handle();
|
||||
@@ -457,21 +476,23 @@ fn confirm_destructive(
|
||||
/// Callers confirm first unless [`live_pane_count`] answered zero; with nothing
|
||||
/// running there is nothing to lose.
|
||||
pub fn stop_workspace(cx: &mut App, workspace: WorkspaceId) {
|
||||
stop_workspace_keeping(cx, workspace, ClearedLayout::Push);
|
||||
let doomed = doomed_pane_ids(cx, workspace);
|
||||
stop_workspace_keeping(cx, workspace, doomed);
|
||||
}
|
||||
|
||||
/// What to do with the record once its panes are dead.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum ClearedLayout {
|
||||
/// Send it to the machine that owns it — the workspace is going to be
|
||||
/// reopened, and it must not reopen claiming panes that no longer exist.
|
||||
Push,
|
||||
/// Leave it alone: the caller is about to delete the record outright, and a
|
||||
/// push racing that delete could put the workspace back on the machine.
|
||||
Discard,
|
||||
/// The pane ids stopping or deleting `workspace` must kill, per its machine's
|
||||
/// mirror. Read this **before** any operation that removes the workspace from
|
||||
/// the mirror — `fire_workspace_op(WorkspaceRemove)` folds the removal in
|
||||
/// synchronously ([`crate::ui::machine_mirror::MachineMirrors::note_workspace_op`]),
|
||||
/// and a list read after it is always empty.
|
||||
fn doomed_pane_ids(cx: &App, workspace: WorkspaceId) -> Vec<u64> {
|
||||
WorkspaceStore::all(cx)
|
||||
.get(workspace)
|
||||
.and_then(|ws| crate::ui::machine_mirror::pane_ids(cx, ws))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, cleared: ClearedLayout) {
|
||||
fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, ids: Vec<u64>) {
|
||||
// A remote workspace's panes live on the remote server, and its pane ids are
|
||||
// *that* daemon's. Sending them here would not fail — it would succeed
|
||||
// against whatever local panes happen to hold those numbers, killing a
|
||||
@@ -482,10 +503,6 @@ fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, cleared: Cleared
|
||||
.get(workspace)
|
||||
.map(|w| w.host_id())
|
||||
.unwrap_or(crate::ui::host_ops::HostId::LOCAL);
|
||||
let ids = WorkspaceStore::all(cx)
|
||||
.get(workspace)
|
||||
.map(|ws| ws.pane_ids())
|
||||
.unwrap_or_default();
|
||||
if !ids.is_empty() {
|
||||
// Off the UI thread: each of these dials `route`, and on a remote
|
||||
// workspace that is an SSH channel per pane. Stopping a four-pane
|
||||
@@ -524,98 +541,39 @@ fn stop_workspace_keeping(cx: &mut App, workspace: WorkspaceId, cleared: Cleared
|
||||
// half-finished action.
|
||||
close_window_for(cx, workspace);
|
||||
WorkspaceStore::close_window(cx, workspace);
|
||||
// Last, and after the window is gone so nothing records the old layout back
|
||||
// over it: the ids we just killed are dead by our own hand, and a record
|
||||
// that still claims them reopens into panes that cannot be attached to.
|
||||
// Locally that is invisible (`alive_panes_on` asks the daemon and gets the
|
||||
// same answer); on a remote workspace nobody asks, so the stale id is the
|
||||
// whole difference between reopening onto fresh shells with the agent
|
||||
// conversation resumed and reopening onto `tty7 — disconnected`.
|
||||
forget_killed_panes(cx, workspace, cleared);
|
||||
// No client-side bookkeeping about the panes remains to correct: the kills
|
||||
// above end the PTYs, the machine's own pane server observes each death,
|
||||
// and the tree's records flip to `live: false` — exactly the state the
|
||||
// next open reads as "revive with a fresh shell".
|
||||
refresh_menu(cx);
|
||||
}
|
||||
|
||||
/// Drop `workspace`'s pane ids, and tell the machine that owns the record.
|
||||
///
|
||||
/// The push is not optional for a remote workspace that is being kept: design
|
||||
/// The remote's `workspaces.json` is the authority, so reopening pulls
|
||||
/// its copy over the client's ([`WorkspaceStore::apply_remote`]) and a
|
||||
/// local-only edit would be undone by the next open — which is the open this
|
||||
/// exists for.
|
||||
fn forget_killed_panes(cx: &mut App, workspace: WorkspaceId, cleared: ClearedLayout) {
|
||||
if !WorkspaceStore::forget_pane_ids(cx, workspace) {
|
||||
return;
|
||||
}
|
||||
if cleared == ClearedLayout::Discard {
|
||||
return;
|
||||
}
|
||||
let Some((host, key, record)) = WorkspaceStore::remote_payload(cx, workspace) else {
|
||||
return;
|
||||
};
|
||||
let Some(connection) = crate::ui::remote_workspace::connection_for(cx, workspace) else {
|
||||
// Not connected, so the panes were not killed either — `kill_pane_on`
|
||||
// needs the same route. The client's copy is still worth clearing: it
|
||||
// is what a reconnect pushes back up.
|
||||
log::info!(
|
||||
"ended sessions on {} without reaching it; the cleared layout goes up on reconnect",
|
||||
host.target
|
||||
);
|
||||
return;
|
||||
};
|
||||
cx.background_executor()
|
||||
.spawn(async move {
|
||||
if let Err(e) = crate::ui::remote_connect::put_remote_layout(&connection, key, record) {
|
||||
log::warn!(
|
||||
"could not tell {} its workspace's panes are gone: {e}",
|
||||
host.target
|
||||
);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Delete a workspace outright: stop it, then forget it entirely. Irreversible
|
||||
/// — nothing about the layout survives.
|
||||
pub fn delete_workspace(cx: &mut App, workspace: WorkspaceId) {
|
||||
// Delete it on the machine that owns it first, while the pointer to it is
|
||||
// still on file. Doing this after `WorkspaceStore::remove` would leave the
|
||||
// record stranded on the remote with no way left to name it.
|
||||
delete_on_remote(cx, workspace);
|
||||
// …and the stop that follows must not push the emptied layout back up: the
|
||||
// delete above is in flight on a background task, and a push landing after
|
||||
// it would recreate the record it just removed.
|
||||
stop_workspace_keeping(cx, workspace, ClearedLayout::Discard);
|
||||
let doomed = delete_from_tree(cx, workspace);
|
||||
stop_workspace_keeping(cx, workspace, doomed);
|
||||
WorkspaceStore::remove(cx, workspace);
|
||||
release_unused_hosts(cx);
|
||||
refresh_menu(cx);
|
||||
}
|
||||
|
||||
/// Forget a remote workspace on the machine that owns it (the
|
||||
/// remote's `workspaces.json` is the authority, so deleting only the client's
|
||||
/// pointer would leave the workspace there and reappear on the next connect).
|
||||
/// The tree half of a delete, in the one order that works: read the kill list
|
||||
/// off the machine mirror **before** firing `WorkspaceRemove`, because firing
|
||||
/// folds the removal into that mirror on the way out and the list read
|
||||
/// afterwards is empty — which is how "N running sessions will be ended" once
|
||||
/// ended zero. Answers the panes the caller must kill.
|
||||
///
|
||||
/// A no-op for a local workspace, and for a remote one this client is not
|
||||
/// currently connected to — there is no way to reach the record, and the delete
|
||||
/// is a user action rather than something to queue and replay later.
|
||||
fn delete_on_remote(cx: &mut App, workspace: WorkspaceId) {
|
||||
let Some(host) = WorkspaceStore::remote_ref(cx, workspace) else {
|
||||
return;
|
||||
};
|
||||
let Some(connection) = crate::ui::remote_workspace::connection_for(cx, workspace) else {
|
||||
log::info!(
|
||||
"deleting the local pointer to a workspace on {} without reaching it",
|
||||
host.target
|
||||
);
|
||||
return;
|
||||
};
|
||||
let key = host.store_key();
|
||||
cx.background_executor()
|
||||
.spawn(async move {
|
||||
if let Err(e) = crate::ui::remote_connect::delete_remote_workspace(&connection, key) {
|
||||
log::warn!("could not delete the workspace on {}: {e}", host.target);
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
/// The op itself still goes before `WorkspaceStore::remove`: the tree is where
|
||||
/// every other client (and the next launch) lists workspaces from, and firing
|
||||
/// after the entry is gone would leave it stranded with no way to name it.
|
||||
fn delete_from_tree(cx: &mut App, workspace: WorkspaceId) -> Vec<u64> {
|
||||
let doomed = doomed_pane_ids(cx, workspace);
|
||||
crate::ui::tree_sync::fire_workspace_op(cx, workspace, |ws| {
|
||||
tty7_core::daemon::control::ControlRequest::WorkspaceRemove { workspace: ws }
|
||||
});
|
||||
crate::ui::tree_sync::forget(cx, workspace);
|
||||
doomed
|
||||
}
|
||||
|
||||
/// Drop the connection to any machine no workspace points at any more.
|
||||
@@ -625,14 +583,14 @@ fn delete_on_remote(cx: &mut App, workspace: WorkspaceId) {
|
||||
/// careful would tear down a live sibling window's host mid-call.
|
||||
fn release_unused_hosts(cx: &mut App) {
|
||||
let live: Vec<_> = WorkspaceStore::all(cx)
|
||||
.workspaces
|
||||
.views
|
||||
.iter()
|
||||
.filter(|w| w.is_remote())
|
||||
.map(|w| w.host_id())
|
||||
.collect();
|
||||
for id in crate::ui::host_registry::HostRegistry::ids(cx) {
|
||||
if !id.is_local() && !live.contains(&id) {
|
||||
crate::ui::remote_connect::RemoteConnections::remove(cx, id);
|
||||
crate::ui::remote_connect::HostLinks::remove(cx, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -657,11 +615,11 @@ fn close_window_for(cx: &mut App, workspace: WorkspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
let (fresh, session) = WorkspaceStore::claim(cx, None);
|
||||
let fresh = WorkspaceStore::claim(cx, None);
|
||||
WindowRegistry::rebind(cx, workspace, fresh);
|
||||
let _ = handle.update(cx, |_, window, cx| {
|
||||
app.update(cx, |app, cx| {
|
||||
app.adopt_workspace(fresh, session, window, cx)
|
||||
app.adopt_workspace(fresh, crate::core::session::Session::default(), window, cx)
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -845,4 +803,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The regression the delete order guards against: `WorkspaceRemove` is
|
||||
/// folded into the machine mirror synchronously on its way out, so a kill
|
||||
/// list read *after* firing it is always empty — the confirm prompt said
|
||||
/// "3 running sessions will be ended" and the delete then ended none.
|
||||
/// `delete_from_tree` must hand back the panes the mirror listed before
|
||||
/// the removal blanked it.
|
||||
#[gpui::test]
|
||||
fn a_delete_reads_its_kill_list_before_the_removal_blanks_the_mirror(
|
||||
cx: &mut gpui::TestAppContext,
|
||||
) {
|
||||
use crate::core::session::{WindowView, WindowViews};
|
||||
use tty7_core::core::machine::{Machine, PaneRecord, Tab, Workspace as TreeWorkspace};
|
||||
|
||||
cx.update(|cx| {
|
||||
let view = WindowView::default();
|
||||
let id = view.id;
|
||||
WorkspaceStore::install_for_test(
|
||||
cx,
|
||||
WindowViews {
|
||||
views: vec![view],
|
||||
active: None,
|
||||
},
|
||||
);
|
||||
crate::ui::machine_mirror::MachineMirrors::install(
|
||||
cx,
|
||||
crate::ui::host_ops::HostId::LOCAL,
|
||||
Machine {
|
||||
workspaces: vec![TreeWorkspace {
|
||||
id,
|
||||
tabs: vec![Tab::leaf(1), Tab::leaf(2), Tab::leaf(3)],
|
||||
..TreeWorkspace::default()
|
||||
}],
|
||||
panes: vec![PaneRecord::new(1), PaneRecord::new(2), PaneRecord::new(3)],
|
||||
},
|
||||
);
|
||||
|
||||
let doomed = delete_from_tree(cx, id);
|
||||
assert_eq!(
|
||||
doomed,
|
||||
vec![1, 2, 3],
|
||||
"every session the confirm prompt counted must be on the kill list"
|
||||
);
|
||||
assert!(
|
||||
doomed_pane_ids(cx, id).is_empty(),
|
||||
"the removal has been folded into the mirror — which is exactly why \
|
||||
the list must be read first"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user