Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/specs/auto-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The standalone app checks for updates on launch and prompts in the Baseboard. **

### Quit-time install

**The updater owns no quit interception** — install runs only when `hasPendingUpdate()` is true, after the quit orchestrator's teardown and save/drain steps (`docs/specs/standalone.md` §Quit flow) (rationale). **It runs in `main`, the window the quit walk tears down last and the only one holding `updater:*`** (`capabilities/main-only.json`); every other window has handed on by then, so nothing it could still be writing outlives the install.
**The updater owns no quit interception** — install runs only when `hasPendingUpdate()` is true, after the quit orchestrator's teardown and save/drain steps (`docs/specs/standalone.md` §Quit flow) (rationale). **It runs in `main`, the window the quit walk tears down last and the only one holding `updater:default`** (`capabilities/main-only.json`); every other window has handed on by then, so nothing it could still be writing outlives the install.

**Only `main` ever checks**, so it is the only window that can hold a download at all — and **closing `main` throws away an approved one**, which lives in that webview's memory. Its close confirmation says so, and is shown for that reason alone even with nothing running (`docs/specs/standalone.md` → "Per-window close"); a session that has closed `main` simply has no update to install until it relaunches (rationale). `installPendingUpdate()` writes the success marker *before* `install()` (§localStorage), and on Windows first awaits bounded sidecar teardown (§Sidecar teardown on Windows). **It never closes the window itself** — exiting the process is `quit_proceed`'s job, after this returns.

Expand Down
2 changes: 1 addition & 1 deletion docs/specs/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ The union projection and its indicators are owned by `docs/specs/alert.md` → W

Source of truth: `WorkspaceWindow` in `lib/src/components/WorkspaceWindow.tsx`; `registerWallHandle` in `lib/src/components/wall/wall-handles.ts`; `closeAll` in `lib/src/components/Wall.tsx`; `requestWorkspaceClose` in `lib/src/components/wall/workspace-lifecycle.ts`; `createWorkspace` / `closeWorkspace` / `renameWorkspace` / `moveWorkspace` / `setActiveWorkspace` in `lib/src/lib/workspace-store.ts`; `getWorkspaceUiSnapshot` in `lib/src/lib/workspace-ui-store.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`.

**Every Workspace verb has a `dor` counterpart** (`docs/specs/dor-cli.md` → "dor workspace"), taking the same route as the strip and the command-mode keys: a command close raises no confirmation, refusing instead, and closes its member Surfaces silently.
**Create, rename, close, and switch have `dor` counterparts** (`docs/specs/dor-cli.md` → "dor workspace"); reorder is strip-only, and transfer and tear-out stay drag-only until `docs/specs/dor-cli.md` → Future "Cross-Window targeting" lands. Each takes the same route as the strip and the command-mode keys: a command close raises no confirmation, refusing instead, and closes its member Surfaces silently.

## Modes

Expand Down
13 changes: 9 additions & 4 deletions docs/specs/standalone.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ so `titleBarStyle`, `hiddenTitle`, `dragDropEnabled` and the CSP carry across
with no second copy of any of them.

**Capabilities are split**: `default.json` covers `main` and the `ws-*` glob,
and `main-only.json` scopes `updater:*` and `core:app:allow-version` to `main`,
and `main-only.json` scopes `updater:default` and `core:app:allow-version` to `main`,
which structurally enforces that the install runs in the window the walk tears
down last (`docs/specs/auto-update.md`). Custom commands need no capability
entry. `standalone/scripts/tauri-conf.test.mjs` pins both.
Expand All @@ -339,7 +339,9 @@ Source of truth: `route` in `standalone/src-tauri/src/routing.rs`,

| Sidecar event | Key | Goes to |
|---|---|---|
| `pty:data`, `terminal:semanticEvents`, `terminal:protocolEvents` | `data.id` | its owner; dropped while the id is mid-transfer |
| `pty:data` | `data.id` | its owner; dropped while the id is mid-transfer, its bytes being in the replay |
| `terminal:semanticEvents` | `data.id` | its owner; dropped while the id is mid-transfer — the target re-derives them from the raw replay, feeding both pane state and its `AlertManager` (rationale) |
| `terminal:protocolEvents` | `data.id` | its owner; **held** while the id is mid-transfer and delivered, in order, behind the replay, which rebuilds none of them; at most `HELD_EVENTS_MAX` (256) per id, overflow dropping the oldest (`held_events_come_back_in_order_and_bounded`) |
| `pty:exit`, `pty:replay` | `data.id` | its owner, never suppressed |
| `pty:list` | `data.forWindow` | the window that asked |
| `alert:*` carrying `data.id` | `data.id` | its owner |
Expand Down Expand Up @@ -540,8 +542,9 @@ below reads that record rather than inferring itself from the suppression map.
"arrived before armed" class of bug (rationale). Rust answers
`pty:requestInit` with **that arrival's ids and no others**; `pty:list` and
each `pty:replay` echo the collector's token. The target resumes over them,
hydrates the notes, seeds each persisted TODO into its own `AlertManager`,
and mounts the Workspace at the drop index.
hydrates the notes and mounts the Workspace at the drop index.
**Must seed persisted alerts before requesting replay**, so the older state
cannot erase WATCHING rebuilt by replay (`standalone/src/workspace-move.test.ts`).
4. **Target adopted** invokes `adopt_done(workspaceId)`. Rust retires the record,
clears what is left of the suppression, and emits `workspace-departed` for
**that Workspace alone** to its own source.
Expand Down Expand Up @@ -571,6 +574,8 @@ below reads that record rather than inferring itself from the suppression map.
source unsuppressed, drop the record, and emit `workspace-arrival-failed`; the
source clears **transferring** and the Workspace is simply still there. With
both ends gone the shells are reaped rather than left owned by a dead label.
**The gap is lost on a hand-back**: suppressed from the invoke with no replay
to follow, it is the one path nothing recovers.
- **`planArrival` never throws into `bootstrap()`.** A refused sole arrival on
the boot path renders a fresh one-pane Workspace, never a blank window.
- **`take_arrivals` does not consume.** The record settles at `adopt_done`, so a
Expand Down
10 changes: 10 additions & 0 deletions docs/specs/standalone.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ minted in `pty_spawn`, so an unowned id is one whose window went away, and the
broadcast reached every sibling's AlertManager — which rang, and offered a TODO,
for a pane none of them showed.

The first hold queued both derived streams, on the premise that neither is in
any replay. Semantic events are: the replay is the raw bytes, OSCs included, and
the target's replay listener re-parses them. The flushed queue then re-applied
`commandStart` on top of state the replay had just rebuilt, and `commandStart`
is not idempotent — it mints a fresh id and consumes the pending command line —
so a transfer that split a command's `commandLine` from its `commandStart` left
the arriving window with a derived title for a command whose real line the
replay had already recovered. What the replay path genuinely did not rebuild was
the AlertManager's copy, and that is a listener fix, not a routing one.

## What a window's `Destroyed` settles

Tauri removes a label from `webview_windows()` only when the window is actually
Expand Down
2 changes: 1 addition & 1 deletion scripts/spec-word-budgets.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
"docs/specs/security-supply-chain.md": 1150,
"docs/specs/security.md": 1900,
"docs/specs/shortcuts.md": 1050,
"docs/specs/standalone.md": 9000,
"docs/specs/standalone.md": 9100,
"docs/specs/terminal-context.md": 900,
"docs/specs/terminal-escapes.md": 3750,
"docs/specs/terminal-state.md": 2350,
Expand Down
21 changes: 21 additions & 0 deletions standalone/sidecar/main-wiring.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// The sidecar's entry wires pty-core to the bundles it requires. These are
// source checks, because loading main.js needs the built bundles and a live
// stdin; each pins one injection whose absence fails silently at runtime.
const { test } = require('node:test');
const assert = require('node:assert');
const { readFileSync } = require('node:fs');
const path = require('node:path');

const source = readFileSync(path.join(__dirname, 'main.js'), 'utf8');

test('pty-core is created with the shared sliceSince, so recovery capture reads a buffer', () => {
// Without it `outputSince` answers '' and `captureAgentRecovery` records
// nothing, with no error anywhere (pty-core.js -> outputSince).
assert.match(source, /\{\s*captureAgentRecovery,\s*createRecoveryStore,\s*sliceSince\s*\}\s*=\s*require\('\.\/recovery\.cjs'\)/);
assert.match(source, /nodePty,\s*\{\s*replay:\s*true,\s*sliceSince\s*\}\)/);
});

test('recovery capture and the record take are answered from pty-core marks', () => {
assert.match(source, /receivedChars:\s*\(id\)\s*=>\s*mgr\.receivedChars\(id\)/);
assert.match(source, /outputSince:\s*\(id,\s*mark\)\s*=>\s*mgr\.outputSince\(id,\s*mark\)/);
});
118 changes: 105 additions & 13 deletions standalone/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ struct RoutingState {
/// dor requestId -> the window handling it, so a cancel reaches the window
/// holding the subscription, watch or completion claim it releases.
dor_targets: HashMap<String, String>,
/// Protocol events that arrived while their id was suppressed, delivered
/// to the new owner behind its replay (`routing::Route::Hold`). Only ever
/// emptied together with `awaiting_replay` (`lift_suppression`).
held: HashMap<String, Vec<routing::HeldEvent>>,
Comment thread
dormouse-bot marked this conversation as resolved.
}

impl RoutingState {
/// `routing::lift_suppression` over this state's two halves. The caller
/// republishes `WindowState::suppressed` after it, still under the lock.
fn lift_suppression(&mut self, id: &str) -> Vec<routing::HeldEvent> {
routing::lift_suppression(&mut self.awaiting_replay, &mut self.held, id)
}
}

#[derive(Default)]
Expand Down Expand Up @@ -139,10 +151,10 @@ impl WindowState {
fn mint(&self, id: &str, label: &str) {
let mut routing = guard(&self.routing);
routing.owners.insert(id.to_string(), label.to_string());
if routing.awaiting_replay.remove(id).is_some() {
self.suppressed
.store(routing.awaiting_replay.len(), Ordering::Relaxed);
}
// Whatever was held belonged to the PTY that never arrived, not this one.
routing.lift_suppression(id);
self.suppressed
.store(routing.awaiting_replay.len(), Ordering::Relaxed);
}

/// Refuse every later `save_session` for `label` (a deliberate close removed
Expand All @@ -167,7 +179,12 @@ impl WindowState {
if suppress {
routing.awaiting_replay.insert(id.clone(), now);
} else {
routing.awaiting_replay.remove(id);
// A hand-back. The gap is lost here: the source was suppressed
// like any other non-owner from the invoke on, and no replay
// follows a hand-back, so the bytes and everything derived from
// them are gone from its pane. A later stage recovers the gap
// (docs/specs/standalone.md -> "Arrival queue").
Comment on lines +185 to +186

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This points at a spec line that says the opposite. docs/specs/standalone.md → "Arrival queue" now reads "The gap is lost on a hand-back: suppressed from the invoke with no replay to follow, it is the one path nothing recovers", and no spec carries a ## Future item for recovering it — standalone.md has no ## Future section at all, and there is no **Scope: workspaces** anywhere under docs/specs/. So a reader who follows the pointer to check what the later stage is finds the claim withdrawn, and nothing will bring it back when a stage does land.

Either drop the promise here, or record it as a ## Future item with a Reserved: line in the body per AGENTS.md → "Spec lifecycle". The suggestion is the former, matching the spec's own wording.

Suggested change
// them are gone from its pane. A later stage recovers the gap
// (docs/specs/standalone.md -> "Arrival queue").
// them are gone from its pane, the one path nothing recovers.

routing.lift_suppression(id);
}
}
self.suppressed
Expand All @@ -178,7 +195,7 @@ impl WindowState {
fn forget_pty(&self, id: &str) {
let mut routing = guard(&self.routing);
routing.owners.remove(id);
routing.awaiting_replay.remove(id);
routing.lift_suppression(id);
self.suppressed
.store(routing.awaiting_replay.len(), Ordering::Relaxed);
}
Expand All @@ -190,7 +207,7 @@ impl WindowState {
fn clear_suppression(&self, ids: &[String]) {
let mut routing = guard(&self.routing);
for id in ids {
routing.awaiting_replay.remove(id);
routing.lift_suppression(id);
}
self.suppressed
.store(routing.awaiting_replay.len(), Ordering::Relaxed);
Expand All @@ -209,7 +226,7 @@ impl WindowState {
let mut routing = guard(&self.routing);
for id in lost.iter().flat_map(|arrival| &arrival.terminal_ids) {
routing.owners.remove(id);
routing.awaiting_replay.remove(id);
routing.lift_suppression(id);
}
let owned = routing.owned_by(label);
for id in &owned {
Expand Down Expand Up @@ -266,6 +283,8 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) {
};

let mut released: Vec<String> = Vec::new();
// Held events an expired suppression releases, flushed to the owner below.
let mut flushed: Vec<(String, Vec<routing::HeldEvent>)> = Vec::new();
let delivery = {
// Before the routing lock, never inside it (§`arrivals`). Nothing is
// transferring in the steady state, so this second acquisition is paid
Expand All @@ -287,6 +306,13 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) {
state
.suppressed
.store(routing.awaiting_replay.len(), Ordering::Relaxed);
for id in &released {
// The sweep already took the map entry; this takes the queue.
let queue = routing.lift_suppression(id);
if let (false, Some(label)) = (queue.is_empty(), routing.owners.get(id)) {
flushed.push((label.clone(), queue));
}
}
}
}

Expand All @@ -300,6 +326,12 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) {
},
) {
Route::Drop => Delivery::Nowhere,
Route::Hold => {
if let Some(id) = data.get("id").and_then(JsonValue::as_str) {
routing::hold_event(&mut routing.held, id, event, data.clone());
}
Delivery::Nowhere
}
Route::Broadcast => Delivery::Broadcast,
Route::EmitTo(label) => Delivery::To(label.to_string()),
// Resolved here, where the focus order is a sibling of the map the
Expand All @@ -318,6 +350,12 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) {
}
};

for (label, queue) in flushed {
for (held_event, held_data) in queue {
let _ = app.emit_to(label.as_str(), held_event.as_str(), &held_data);
}
}

let mut delivered: Option<&str> = None;
match &delivery {
Delivery::Nowhere => {}
Expand Down Expand Up @@ -361,11 +399,21 @@ fn dispatch_sidecar_event(app: &AppHandle, event: &str, data: JsonValue) {
}
"pty:replay" => {
if let Some(id) = id() {
let mut routing = guard(&state.routing);
routing.awaiting_replay.remove(id);
state
.suppressed
.store(routing.awaiting_replay.len(), Ordering::Relaxed);
let queue = {
let mut routing = guard(&state.routing);
let queue = routing.lift_suppression(id);
state
.suppressed
.store(routing.awaiting_replay.len(), Ordering::Relaxed);
queue
};
// Behind the replay, to the window that just received it: the
// events describe bytes the replay carried.
if let Some(label) = delivered {
for (held_event, held_data) in queue {
let _ = app.emit_to(label, held_event.as_str(), &held_data);
}
}
}
}
"dor:controlRequest" => {
Expand Down Expand Up @@ -4334,12 +4382,56 @@ mod tests {
state.reassign(&["pane-a".to_string()], "ws-2", true);
assert_eq!(state.suppressed.load(Ordering::Relaxed), 1);

super::routing::hold_event(
&mut guard(&state.routing).held,
"pane-a",
"terminal:protocolEvents",
serde_json::json!({"n": 1}),
);

state.mint("pane-a", "main");
assert!(guard(&state.routing).awaiting_replay.is_empty());
// Nothing held for the PTY that never arrived survives under its id.
assert!(guard(&state.routing).held.is_empty());
assert_eq!(state.suppressed.load(Ordering::Relaxed), 0);
assert_eq!(state.owned_by("main"), vec!["pane-a".to_string()]);
}

/// Every way out of a suppression takes the held queue with it: a queue
/// left behind would be flushed ahead of the *next* transfer's own gap.
#[test]
fn every_lift_of_a_suppression_takes_its_held_queue() {
let state = super::WindowState::default();
let ids = ["pane-a".to_string()];
let queue_up = || {
state.reassign(&ids, "ws-2", true);
super::routing::hold_event(
&mut guard(&state.routing).held,
"pane-a",
"terminal:protocolEvents",
serde_json::json!({"n": 1}),
);
assert_eq!(state.suppressed.load(Ordering::Relaxed), 1);
};
let lifted = || {
let routing = guard(&state.routing);
routing.awaiting_replay.is_empty() && routing.held.is_empty()
};

queue_up();
state.clear_suppression(&ids);
assert!(lifted());
assert_eq!(state.suppressed.load(Ordering::Relaxed), 0);

queue_up();
state.reassign(&ids, "main", false);
assert!(lifted());

queue_up();
state.forget_pty("pane-a");
assert!(lifted());
}

#[test]
fn sweep_orphan_session_temps_removes_only_temps() {
let dir = TempDir::new("sessions-sweep");
Expand Down
Loading