a73x

67a1e976

feat: add native host and session pickers

a73x   2026-09-05 08:55

Commit message
feat: add native host and session pickers

CLAUDE.md
Old New
@@ -78,8 +78,8 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module.
78 |---|---| 78 |---|---|
79 | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test | 79 | `src/engine/` | `term`(`term.zig`) — `protocol` `replica` `grid` · `engine`(`engine.zig`) — `delta` — the daemon's ghostty-vt; no client row imports it outside a test |
80 | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` | 80 | `src/server/` | `daemon`(`server.zig`) — `server_agent` `server_sessions` `cmd` `shellint` `quic_server` `upgrade` `server_test_*` · `pty` |
81 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` `session_pump` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) | 81 | `src/client/` | `client` — `client_core` `hosts` `handoff` `layout` `keymap` `askpass` `session_pump` `buffered_wire` `open_wait` `resolver` `discovery` · `webhub` · `wasm_core` `client_core_wasm_check` (wasm roots the build wires outside the table) |
82 | `src/gui/` | `native`(`native.zig`) — `workspace` `runtime` `font` `atlas` `quads` `gl` `frame` `bench` | 82 | `src/gui/` | `native`(`native.zig`) — `workspace` `runtime` `picker` `font` `atlas` `quads` `gl` `frame` `bench` |
83 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` | 83 | `src/tui/` | `wall`(`wallview.zig`) — `interact` `paint` `select` `predict` `wall_host` `wall_picker` `wall_pump` `wall_layout` `wall_test_*` |
84 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `muxg`(native viewer) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) | 84 | `src/cli/` | `mux`(dispatch) — `main`(daemon) `mux_main`(client) `webhub_main`(hub) · `muxg`(native viewer) · `agent`(`muxa.zig`) · `cliflags`(`flags.zig`) |
85 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) | 85 | `src/os/` | `server_os`(`server_os.zig`) — `server_os_linux` `server_os_macos` · `client_os`(`client_os.zig`) — `client_os_linux` `client_os_macos` · `spawn` — the platform layer, one row per side so the client never links a fork or a pty; imports nothing of ours (spec 2026-09-03) |
README.md
Old New
@@ -26,7 +26,7 @@ link. It is outside the default build and CI gates:
26 26
27 ```sh 27 ```sh
28 make native # build muxg and run its no-window unit tests 28 make native # build muxg and run its no-window unit tests
29 make native-e2e # ReleaseSafe single- and two-pane integration checks; needs python3 29 make native-e2e # ReleaseSafe pane and picker integration checks; needs python3
30 ./zig-out/bin/muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N] 30 ./zig-out/bin/muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N]
31 ``` 31 ```
32 32
@@ -47,24 +47,35 @@ The end-to-end leg checks the real build mode, reads rendered pixels back
47 from OpenGL, and measures a 20 ms p99 window-side budget under concurrent 47 from OpenGL, and measures a 20 ms p99 window-side budget under concurrent
48 output; pump apply time is reported separately. 48 output; pump apply time is reported separately.
49 49
50 The first native tiling sprint supports one staged second target: 50 Open the initial terminal, then use the native picker to add more panes:
51 51
52 ```sh 52 ```sh
53 ./zig-out/bin/muxg alpha --session work --next-target beta --next-session logs 53 ./zig-out/bin/muxg alpha --session work
54 # Local second daemon: --next-target '--sock /tmp/second.sock' --next-session logs
55 ``` 54 ```
56 55
57 Press `Ctrl+\`, then **v** for side by side or **b** for above/below. 56 Press `Ctrl+\`, then **v** for side by side or **b** for above/below.
58 This shows the intended split. Press the prefix again, then **Enter**, to insert 57 This shows the intended split. Press the prefix again, then **Enter**, to open
59 the staged target. Cancellation creates no second pane or session. The split stays 58 the host picker. Arrows or **j/k** select a row; Enter chooses it. Select a host,
60 on the pane where it was armed, even if focus moves before insertion. 59 then an existing session or **New session...**, which asks for a name. **Add host...**
60 accepts an SSH host, `quic://HOST[:PORT]`, or `--sock PATH` and saves it to the shared
61 host catalogue. You can also click picker rows. The split stays on the pane where
62 it was armed, even if focus moves before insertion. Prefix + Enter without an
63 armed direction defaults to side by side.
64
65 Esc in a name editor returns without submitting; Esc in sessions returns to hosts;
66 Esc in hosts closes the picker while preserving the pending split. Cancelling
67 before submitting a new session creates nothing. A name collision is refused.
68 If creation was submitted but its reply was lost, the picker reports an unknown
69 outcome; refresh the session list before retrying. Existing-session selection
70 never recreates a vanished shell. Older daemons can serve existing sessions but
71 may leave the new creation request unanswered; upgrade the daemon to enable it.
61 72
62 Prefix followed by **h/j/k/l** or an arrow moves focus; clicking a pane also focuses 73 Prefix followed by **h/j/k/l** or an arrow moves focus; clicking a pane also focuses
63 it. Prefix then **Esc** cancels a pending split. Press the prefix twice to send its 74 it. Prefix then **Esc** cancels a pending split. Press the prefix twice to send its
64 normal terminal encoding through once. Window resizing updates every pane's PTY. 75 normal terminal encoding through once. Window resizing updates every pane's PTY.
65 An exited or unavailable pane leaves other panes usable. 76 An exited or unavailable pane leaves other panes usable.
66 77
67 Host/session pickers, divider resizing, and saved native layouts follow in later 78 Divider resizing and saved native layouts follow in later
68 sprints. Tabs are represented in the ownership model but have no UI yet. Native 79 sprints. Tabs are represented in the ownership model but have no UI yet. Native
69 layout choices are independent of terminal mux; both clients use the same daemon 80 layout choices are independent of terminal mux; both clients use the same daemon
70 sessions. 81 sessions.
RETRO.md
Old New
@@ -40,21 +40,86 @@ reasons below. Passing a test does not by itself complete an ergonomic action.
40 - [ ] Demo host → existing/new session → insertion with the user. Verify that 40 - [ ] Demo host → existing/new session → insertion with the user. Verify that
41 the picker supplies the target without requiring staged CLI flags, and capture 41 the picker supplies the target without requiring staged CLI flags, and capture
42 the user's feedback on direction selection, cancellation, and focus. 42 the user's feedback on direction selection, cancellation, and focus.
43 - [ ] Make connection setup and discovery cancellable. Verify stalled dialing, 43 - [x] Make connection setup and discovery cancellable. Verify stalled dialing,
44 slow replies, cancellation, shutdown, and stale results after target changes; 44 slow replies, cancellation, shutdown, and stale results after target changes;
45 inherited synchronous Unix connect and DNS resolution need explicit treatment. 45 inherited synchronous Unix connect and DNS resolution need explicit treatment.
46 - [ ] Resolve attach-existing versus create semantics. Test a session vanishing 46 - [x] Resolve attach-existing versus create semantics. Test a session vanishing
47 between listing and attachment, and document behavior with older daemons. 47 between listing and attachment, and document behavior with older daemons.
48 - [ ] Keep the implementation/reviewer pair bounded and complete the refactoring 48 - [x] Keep the implementation/reviewer pair bounded and complete the refactoring
49 package before final acceptance. Record concrete findings resolved, duplicated 49 package before final acceptance. Record concrete findings resolved, duplicated
50 or obsolete code removed, and any remaining debt with its next owner/package. 50 or obsolete code removed, and any remaining debt with its next owner/package.
51 51
52 ### Sprint 2 follow-up 52 ### Sprint 2 follow-up
53 53
54 To complete at the next retrospective: 54 See the Sprint 2 record below. The two ergonomic actions remain open: trial
55 defaults were proposed and documented before UI implementation, and a working
56 demo was offered, but the user has not yet confirmed the flow or supplied trial
57 feedback. Automated acceptance does not close those actions.
55 58
56 - Which actions were completed, and what evidence demonstrates that? 59 ## Native tiling — Sprint 2, 2026-09-05
57 - Which actions remain open, why, and where will they be addressed? 60
58 - Did the user trial expose another assumption that should have been settled 61 Implemented host → existing/new session → insertion, including Add host,
59 before implementation? 62 nested splits, keyboard and scaled mouse selection, cancellation, and visible
60 - Did delegation and cleanup improve the result enough to justify their cost? 63 errors. Removed the staged-target CLI flags. Both clients still share daemon
64 sessions; native layout policy remains separate from terminal mux.
65
66 ### What worked
67
68 - The implementer/reviewer pair resolved concrete failures directly: SSH input
69 ownership, QUIC cleanup after cancellation, a child ignoring termination,
70 resize and Enter arriving together, and stale creation outcomes. The GUI
71 harness reproduced the resize bug before verifying its fix.
72 - Existing zero-size attachment already provides atomic join-only semantics.
73 Reusing it avoided changing replay or legacy attachment behavior. Explicit
74 creation needed a narrow new daemon verb to refuse competing names safely.
75 - Independent checks used daemon state and actual pixels, including the prior
76 sprint's daemon and real SSH/QUIC hosts. An unanswered creation remains an
77 unknown outcome rather than triggering an unsafe fallback.
78 - Cleanup removed duplicate target copying, stream buffering, and owned-child
79 teardown. One pure insertion plan now drives both capacity checks and committed
80 insertion; checking room consumes no pane ID or provisional allocation.
81
82 ### What to change
83
84 - Shared cancellation work needs a matrix of input owners: first local attach,
85 first SSH attach, reconnect, and a GUI cancel pipe. Final repository CI caught
86 early local stdin consumption after the narrower tests had passed. The fix
87 preserves queued commands until attachment; regressions check both successful
88 detach and the existence of the newly requested session.
89 - Freeze owned files before starting compilation gates. An early broad run
90 overlapped an in-progress module edit and produced a transient compile failure.
91 - Updating a demo harness requires checking its observations too. Remote checks
92 initially assumed a missing-session dump failed and that quiet picker targets
93 printed fallback narration to GUI stderr. Read-only status and the real remote
94 endpoint log supplied the correct evidence; all fixtures were cleaned up.
95
96 ### Evidence and remaining work
97
98 Connection cancellation is covered by saturated Unix sockets, stalled resolver
99 children, partial replies, target changes, and shutdown during an actual dispatched
100 request. Join-only and create-only behavior is covered by disappearing sessions,
101 competing creators, and an actual prior-sprint daemon. Native Wayland at 200% and
102 the VM at `10.78.5.4` both passed the picker workflow. Exact final gate results
103 are recorded in the native tiling delivery plan.
104
105 The remaining product gate is the user's ergonomic trial. No feedback has been
106 inferred from test passes. New-session creation requires the updated daemon;
107 existing-session selection remains compatible with the prior daemon. Divider
108 resizing, persistence, and recovery commands remain in their planned sprints.
109
110 ### Actions before and during Sprint 3
111
112 Owner: the sprint lead, with bounded implementation and adversarial review.
113 At the next retrospective, mark each action with evidence or a carry-forward reason.
114
115 - [ ] Close the two outstanding picker ergonomic actions with the user before
116 expanding the interaction model. Record feedback on direction, back/cancel,
117 focus, and the shared host catalogue.
118 - [ ] Agree divider hit areas and keyboard resize increments before UI work;
119 reuse one sizing policy for drag, keyboard input, and minimum constraints.
120 - [ ] Exercise nested divider movement at 200% scale and after a display-scale
121 change. Check both PTY sizes and erased pixels, not only model rectangles.
122 - [ ] Include batched resize/input events and stale asynchronous completion in
123 acceptance; avoid observations that accidentally repair the state under test.
124 - [ ] Keep the bounded implementation/review pair and explicit cleanup package.
125 Freeze source before final gates and record any retained debt with an owner.
build.zig
Old New
@@ -1165,8 +1165,12 @@ pub fn build(b: *std.Build) void {
1165 native_tiling.addArtifactArg(mux_exe); 1165 native_tiling.addArtifactArg(mux_exe);
1166 native_tiling.addArtifactArg(muxg_exe); 1166 native_tiling.addArtifactArg(muxg_exe);
1167 native_tiling.step.dependOn(&native_e2e.step); 1167 native_tiling.step.dependOn(&native_e2e.step);
1168 const native_picker = b.addSystemCommand(&.{ "python3", "-B", "test/native_picker.py" });
1169 native_picker.addArtifactArg(mux_exe);
1170 native_picker.addArtifactArg(muxg_exe);
1171 native_picker.step.dependOn(&native_tiling.step);
1168 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); 1172 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)");
1169 native_e2e_step.dependOn(&native_tiling.step); 1173 native_e2e_step.dependOn(&native_picker.step);
1170 1174
1171 const soak = b.addSystemCommand(&.{"test/soak.sh"}); 1175 const soak = b.addSystemCommand(&.{"test/soak.sh"});
1172 // The same list the e2e step passes, in the same order: soak IS that 1176 // The same list the e2e step passes, in the same order: soak IS that
docs/superpowers/plans/2026-09-05-native-tiling.md
Old New
@@ -1,7 +1,7 @@
1 # Native tiling — delegated delivery plan 1 # Native tiling — delegated delivery plan
2 2
3 Design authority: [native tiling spec](../specs/2026-09-05-native-tiling-design.md). 3 Design authority: [native tiling spec](../specs/2026-09-05-native-tiling-design.md).
4 Status: Sprint 1 implemented, refactored, reviewed, and validated; ready for ergonomic trial. 4 Status: Sprint 2 implemented, reviewed, and validated; user ergonomic feedback outstanding.
5 5
6 ## Working agreement 6 ## Working agreement
7 7
@@ -308,10 +308,81 @@ the isolated mouse group and full rerun passed without CLI changes. Logs:
308 `/tmp/muxg-prefix-ci-recheck.log`. Sprint lessons and checkable next-sprint 308 `/tmp/muxg-prefix-ci-recheck.log`. Sprint lessons and checkable next-sprint
309 actions live separately in [RETRO.md](../../../RETRO.md). 309 actions live separately in [RETRO.md](../../../RETRO.md).
310 310
311 Retained debt for package 2A: transport opening still has inherited synchronous 311 At the Sprint 1 handoff, Unix connect and DNS resolution remained synchronous;
312 Unix connect and DNS resolution before cancellable waits. Broadcast cancellation 312 broadcast cancellation alone did not bound those stages. Package 2A resolves this
313 improves multi-pane shutdown but does not make those stages interruptible. Extend 313 debt with cancellable connection setup, an owned resolver helper, and explicit
314 transport cancellation with discovery/lifecycle work and add stalled-connect/DNS 314 stalled-connect/DNS checks. Divider resizing, GUI layout persistence, and tab UI
315 checks there; do not claim a universal shutdown bound from attached/missing-socket 315 retain their later-sprint scope.
316 tests. GUI sessions/layout persistence, picker UI, divider resizing, and tab UI 316
317 remain the explicitly deferred later sprints. 317 ### Sprint 2 — 2026-09-05
318
319 Sprint commit: `feat: add native host and session pickers`.
320
321 Packages 2A and 2B are implemented. The host/session picker supplies insertion
322 targets; Add host records a validated shared-catalogue entry, and New session
323 requests an explicit name. Keyboard navigation, modal text input, scaled row
324 clicks, nested splits, back/cancel, retained attachment failures, and error notices
325 are functional. The temporary `--next-target` / `--next-session` flags are removed.
326
327 Discovery jobs own their target strings, thread, cancellation channel, and result.
328 Request generations and originating tab/pane identities reject stale completions.
329 DNS uses a cancellable exec helper; Unix connect, handshake, partial frame reads,
330 and queued writes share cancellation and deadline handling. Forced teardown reaps
331 owned children even if they ignore ordinary termination. Initial terminal-client
332 stdin remains owned by its command loop or SSH authentication, as appropriate.
333
334 Existing selection uses the daemon's existing zero-size join-only attach, followed
335 by a size claim after admission; reconnect/resync retains that policy. Explicit
336 creation adds `create_req`/`create_reply` without changing cells or legacy attach
337 semantics. The daemon atomically refuses a name collision without attachment or
338 resize. Old daemons may ignore creation; timeout/cancellation after submission
339 reports uncertainty with no automatic fallback, retry, or deletion.
340
341 Package 2D cleanup and adversarial review completed: one shared buffered wire,
342 target deep copy, and owned-child cleanup path; one pure insertion plan for preview
343 and commit; removed staging code. Review resolved first-attach stdin ownership,
344 post-handshake QUIC cleanup, forced-child shutdown, modal key repeats, captured tab
345 identity, and known/unknown creation outcomes. The GUI harness reproduced a
346 resize-plus-Enter event-order failure before the fix and verified it afterward.
347
348 Validation on the final refactored code:
349
350 - `ZIG_GLOBAL_CACHE_DIR=/tmp/muxg-zig-cache deps/zig/zig build native native-test
351 -Doptimize=ReleaseSafe --summary all`: 27 native tests passed.
352 - `ZIG_GLOBAL_CACHE_DIR=/tmp/muxg-zig-cache
353 MUXG_OLD_DAEMON=/tmp/muxg-sprint2-old-mux make native-e2e`: all 10 viewer,
354 14 tiling, and 10 picker checkpoints passed. The preserved Sprint 1 daemon
355 served existing sessions and safely left explicit creation unanswered.
356 Final flood input-to-painted-frame latency was 81 ms; frame p99 was 81 us.
357 Logs: `/tmp/muxg-sprint2-native-final.log` and
358 `/tmp/muxg-sprint2-native-e2e-final.log`; picker artifacts:
359 `/tmp/muxg-tiling-3e0ymjj5`.
360 - Native Wayland at 200% passed picker rendering, a row click converted from
361 physical to logical coordinates, creation, nested selection, both PTY sizes,
362 and independent pane pixels. Log: `/tmp/muxg-sprint2-wayland-final.log`;
363 artifacts: `/tmp/muxg-tiling-x_9hwpwa`.
364 - Real `ubuntu@10.78.5.4` passed session listing → New session → pane over both
365 actual SSH fallback and direct QUIC, with independent input, last-painted
366 text, confined pixels, refocus, and sessions surviving GUI close. The helper
367 stopped only its dedicated daemons, verified removal of its remote directory,
368 and left the VM running. Log: `/tmp/muxg-sprint2-remote-final.log`;
369 artifacts: `/tmp/muxg-tiling-jq51irp6`.
370
371 The first repository gate caught a local terminal-client regression: opening a
372 socket consumed a preloaded detach chord as a dial abort. The corrected policy
373 leaves initial local stdin untouched, while GUI cancellation remains active.
374 The new deterministic socket/stdio regression requires both exit zero and a
375 newly created session, preventing an early-success false pass. All 212 client
376 tests and the focused boot (15) and mouse (10) scenarios passed after the fix.
377 `ZIG_GLOBAL_CACHE_DIR=/tmp/muxg-zig-cache make ci` passed on the final source:
378 formatting, unit tests, shell syntax, and source checks; 115 end-to-end scenarios
379 with 38 convergence points; all 10 agent checks; and throughput limits. Throughput
380 reported 5 ms solo, 42 ms attached, and 90 ms detached repaint. Final log:
381 `/tmp/muxg-sprint2-ci-verified.log`.
382
383 Package 2C remains open for the user's trial. Defaults were proposed before UI
384 implementation, and a working 200% demo and trial controls were supplied; no user
385 confirmation or feedback has been inferred. See [RETRO.md](../../../RETRO.md) for
386 the carried ergonomic actions and explicit Sprint 3 actions. Do not begin Sprint 3
387 until the outstanding trial has been addressed. Divider resizing, native layout
388 persistence, and recovery commands retain their planned scope in Sprints 3 and 4.
docs/superpowers/specs/2026-09-05-native-tiling-design.md
Old New
@@ -1,6 +1,6 @@
1 # Native tiling — draft design and delivery plan 1 # Native tiling — draft design and delivery plan
2 2
3 2026-09-05. Status: Sprint 1 implemented and validated; ready for ergonomic trial. 3 2026-09-05. Status: Sprint 2 insertion workflow implemented and reviewed; user ergonomic trial outstanding.
4 4
5 Execution: [delegated work packages and review gates](../plans/2026-09-05-native-tiling.md). 5 Execution: [delegated work packages and review gates](../plans/2026-09-05-native-tiling.md).
6 6
@@ -65,15 +65,15 @@ they are not prerequisites for the two-pane deliverable.
65 65
66 The defaults below are proposals to trial, rather than additional requirements. 66 The defaults below are proposals to trial, rather than additional requirements.
67 67
68 Horizontal means side by side, with the new pane to the right. Vertical means 68 Use side-by-side and above/below in the interface: `v` names the vertical
69 above/below, with the new pane below. Show those words and a small preview so 69 divider, with the new pane to the right; `b` puts the new pane below. Show the
70 the orientation is unambiguous. 70 pending direction at its originating pane so the orientation is unambiguous.
71 71
72 Direction selection arms the focused pane for one insertion and changes no 72 Direction selection arms the focused pane for one insertion and changes no
73 geometry. Opening the picker captures that pane and direction. Host selection 73 geometry. Opening the picker captures that pane and direction. Host selection
74 opens its sessions; New session requests a name and creates it explicitly. 74 opens its sessions; New session requests a name and creates it explicitly.
75 Only committing a session selection inserts the pane and focuses it. Cancelling 75 Only committing a session selection inserts the pane and focuses it. Cancelling
76 either level creates no pane or session. Esc in sessions returns to hosts; 76 either level before committing creates no pane or session. Esc in sessions returns to hosts;
77 Esc in hosts dismisses the picker. Keep the pending direction visible until 77 Esc in hosts dismisses the picker. Keep the pending direction visible until
78 insertion or explicit cancellation. Start with side-by-side as the default. 78 insertion or explicit cancellation. Start with side-by-side as the default.
79 The pending split remains bound to its original pane if focus moves; its preview 79 The pending split remains bound to its original pane if focus moves; its preview
@@ -83,6 +83,32 @@ Initially, split the armed leaf into two equally sized children. Other panes
83 retain their allocated space. Repeated insertion semantics can be adjusted after 83 retain their allocated space. Repeated insertion semantics can be adjusted after
84 the first ergonomic trial; do not silently rebalance unrelated branches. 84 the first ergonomic trial; do not silently rebalance unrelated branches.
85 85
86 Sprint 2 picker trial defaults (proposed to the user before implementation):
87 prefix + Enter opens hosts; arrows or `j/k` move selection, Enter chooses a row.
88 The shared host catalogue supplies hosts, with an Add host row opening a target
89 editor. Include the current pane's target even if it is absent from that catalogue.
90 The session list offers existing names and a New session row opening a name
91 editor. Editors accept text and Backspace; Enter confirms and Esc returns without
92 submitting. Escape from sessions returns to hosts; from hosts it closes the
93 picker, preserving the armed split. Escape outside the picker cancels that split.
94 The popup owns keyboard and mouse input while open; underlying panes keep painting.
95 Discovery errors, invalid selections, and creation refusals stay in the picker
96 and leave existing pane geometry unchanged. Choosing a listed existing session
97 commits its pane; a subsequent attachment failure remains visible in that pane,
98 following Sprint 1's lifecycle behavior. A successful creation reply also commits
99 insertion, followed by join-only attachment. If a resize or allocation failure
100 prevents insertion after creation succeeded, report the created session so the
101 user can select it again; never end it automatically.
102
103 Selecting an existing session uses a zero-size join-only attach, then claims
104 the pane size after admission. This policy also applies to reconnects and resyncs;
105 a vanished session is never recreated. Explicit creation uses a separate atomic
106 create-only request: a name collision is an error, never an attachment or resize.
107 An older daemon can still serve existing sessions, but may ignore creation requests.
108 A timeout does not distinguish that case from a lost reply. Once Enter submits
109 creation, cancellation or timeout may leave its outcome unknown; report that and
110 allow a fresh session listing, without automatic retry or destructive rollback.
111
86 Keyboard prefix for the ergonomic trial: `Ctrl+\`, matching terminal mux. 112 Keyboard prefix for the ergonomic trial: `Ctrl+\`, matching terminal mux.
87 Press it twice to forward `Ctrl+\` to a terminal application. The native client 113 Press it twice to forward `Ctrl+\` to a terminal application. The native client
88 attaches directly to daemon sessions; nesting terminal mux is not a design requirement. 114 attaches directly to daemon sessions; nesting terminal mux is not a design requirement.
src/cli/mux.zig
Old New
@@ -37,6 +37,7 @@ pub fn main() !u8 {
37 37
38 const args = try std.process.argsAlloc(alloc); 38 const args = try std.process.argsAlloc(alloc);
39 defer std.process.argsFree(alloc, args); 39 defer std.process.argsFree(alloc, args);
40 if (try @import("client").resolver.helper(alloc, args[1..])) |code| return code;
40 41
41 // Named modes receive a slice beginning with their mode word, matching the 42 // Named modes receive a slice beginning with their mode word, matching the
42 // argv shape expected by their parsers. The client has no mode word and 43 // argv shape expected by their parsers. The client has no mode word and
src/cli/muxg.zig
Old New
@@ -1,4 +1,4 @@
1 //! Native workspace entry; one primary attachment and one optional staged target. 1 //! Native workspace entry; subsequent attachments are chosen in the picker.
2 const std = @import("std"); 2 const std = @import("std");
3 const native = @import("native"); 3 const native = @import("native");
4 const client = @import("client"); 4 const client = @import("client");
@@ -18,8 +18,6 @@ const usage =
18 \\ --via a command whose stdio is the daemon 18 \\ --via a command whose stdio is the daemon
19 \\ --key the QUIC key file (or MUX_KEY_FILE) 19 \\ --key the QUIC key file (or MUX_KEY_FILE)
20 \\ --font-px font pixels at 100% display scale (default 16) 20 \\ --font-px font pixels at 100% display scale (default 16)
21 \\ --next-target stage one target specification for prefix v/b then prefix Enter
22 \\ --next-session session name for the staged target (required with --next-target)
23 \\ --help --version 21 \\ --help --version
24 \\ 22 \\
25 ; 23 ;
@@ -30,27 +28,9 @@ const Arguments = struct {
30 key: ?[]const u8 = null, 28 key: ?[]const u8 = null,
31 session: ?proto.SessionName = null, 29 session: ?proto.SessionName = null,
32 font_px: u16 = 16, 30 font_px: u16 = 16,
33 _next_target: ?[]const u8 = null,
34 _next_session: ?[]const u8 = null,
35 _next_targets: usize = 0,
36 _next_sessions: usize = 0,
37 _target: ?[]const u8 = null, 31 _target: ?[]const u8 = null,
38 _targets: usize = 0, 32 _targets: usize = 0,
39 33
40 pub fn extra(self: *Arguments, rest: []const [:0]const u8) usize {
41 if (rest.len < 2) return 0;
42 if (std.mem.eql(u8, rest[0], "--next-target")) {
43 self._next_target = rest[1];
44 self._next_targets += 1;
45 return 2;
46 }
47 if (std.mem.eql(u8, rest[0], "--next-session")) {
48 self._next_session = rest[1];
49 self._next_sessions += 1;
50 return 2;
51 }
52 return 0;
53 }
54 pub fn positional(self: *Arguments, word: []const u8) bool { 34 pub fn positional(self: *Arguments, word: []const u8) bool {
55 self._target = word; 35 self._target = word;
56 self._targets += 1; 36 self._targets += 1;
@@ -70,6 +50,7 @@ pub fn main() !u8 {
70 defer argv_arena.deinit(); 50 defer argv_arena.deinit();
71 const argv_alloc = argv_arena.allocator(); 51 const argv_alloc = argv_arena.allocator();
72 const args = try std.process.argsAlloc(argv_alloc); 52 const args = try std.process.argsAlloc(argv_alloc);
53 if (try client.resolver.helper(alloc, args[1..])) |code| return code;
73 54
74 var o: Arguments = .{}; 55 var o: Arguments = .{};
75 cliflags.parseStrict(Arguments, &o, args[1..]) catch |e| return cliflags.exitFor(e, usage, "muxg", std.fmt.comptimePrint("0.0.1 ({s})", .{@tagName(@import("builtin").mode)})); 56 cliflags.parseStrict(Arguments, &o, args[1..]) catch |e| return cliflags.exitFor(e, usage, "muxg", std.fmt.comptimePrint("0.0.1 ({s})", .{@tagName(@import("builtin").mode)}));
@@ -85,29 +66,13 @@ pub fn main() !u8 {
85 const session = if (o.session) |n| n.name else ""; 66 const session = if (o.session) |n| n.name else "";
86 const key = std.posix.getenv("MUX_KEY_FILE"); 67 const key = std.posix.getenv("MUX_KEY_FILE");
87 68
88 if (o._next_targets > 1 or o._next_sessions > 1 or (o._next_target == null) != (o._next_session == null)) {
89 std.debug.print("muxg: provide --next-target and --next-session exactly once together\n", .{});
90 return 2;
91 }
92 var next: @FieldType(native.frame.Options, "next") = null;
93 if (o._next_target) |word| {
94 const name = o._next_session.?;
95 if (!proto.validSessionName(name)) {
96 std.debug.print("muxg: invalid --next-session\n", .{});
97 return 2;
98 }
99 next = .{ .target = resolve(argv_alloc, word, o.key orelse key) catch |err| {
100 std.debug.print("muxg: bad staged target: {s}\n", .{@errorName(err)});
101 return 2;
102 }, .session = name };
103 }
104 const target: client.Target = if (o.via) |cmd| .{ .via = cmd } else if (o._target) |word| resolve(argv_alloc, word, o.key orelse key) catch |err| { 69 const target: client.Target = if (o.via) |cmd| .{ .via = cmd } else if (o._target) |word| resolve(argv_alloc, word, o.key orelse key) catch |err| {
105 std.debug.print("muxg: bad target: {s}\n", .{@errorName(err)}); 70 std.debug.print("muxg: bad target: {s}\n", .{@errorName(err)});
106 return 2; 71 return 2;
107 } else .{ .sock = if (o.sock) |path| path else (try sockpath.defaultOrExplain(argv_alloc, "muxg") orelse return 1) }; 72 } else .{ .sock = if (o.sock) |path| path else (try sockpath.defaultOrExplain(argv_alloc, "muxg") orelse return 1) };
108 return native.run(alloc, .{ 73 return native.run(alloc, .{
109 .target = target, 74 .target = target,
110 .next = next, 75 .key_path = o.key orelse key,
111 .session = session, 76 .session = session,
112 .font_px = o.font_px, 77 .font_px = o.font_px,
113 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"), 78 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"),
src/client/buffered_wire.zig
Old New
@@ -0,0 +1,76 @@
1 const std = @import("std");
2 const client = @import("client.zig");
3 const proto = @import("term").protocol;
4 // Incremental reads and queued writes keep partial stream frames and a
5 // peer which stops reading from blocking the mailbox or stop(). QUIC keeps
6 // its existing framing and outgoing queue in Link.
7 pub const Wire = struct {
8 alloc: std.mem.Allocator,
9 tr: *client.Transport,
10 input: std.ArrayList(u8) = .empty,
11 output: std.ArrayList(u8) = .empty,
12
13 pub fn init(alloc: std.mem.Allocator, tr: *client.Transport) !Wire {
14 switch (tr.link) {
15 .fd => |fd| try nonblocking(fd),
16 .pipe => |p| {
17 try nonblocking(p.r);
18 try nonblocking(p.w);
19 },
20 .quic => {},
21 }
22 return .{ .alloc = alloc, .tr = tr };
23 }
24 pub fn deinit(self: *Wire) void {
25 self.input.deinit(self.alloc);
26 self.output.deinit(self.alloc);
27 }
28 pub fn writeFd(self: *Wire) std.posix.fd_t {
29 return switch (self.tr.link) {
30 .fd => |fd| fd,
31 .pipe => |p| p.w,
32 .quic => self.tr.pollFd(),
33 };
34 }
35 pub fn pending(self: *Wire) bool {
36 return if (self.tr.link == .quic) self.tr.link.quic.qout.items.len > 0 else self.output.items.len > 0;
37 }
38 pub fn send(self: *Wire, kind: proto.MsgType, payload: []const u8) !void {
39 if (self.tr.link == .quic) return self.tr.writeFrame(kind, payload);
40 try proto.appendFrame(&self.output, self.alloc, kind, payload);
41 try self.flush();
42 }
43 pub fn flush(self: *Wire) !void {
44 if (self.tr.link == .quic) return self.tr.flushQuic();
45 if (self.output.items.len == 0) return;
46 const n = std.posix.write(self.writeFd(), self.output.items) catch |err| switch (err) {
47 error.WouldBlock => return,
48 else => return err,
49 };
50 self.output.replaceRangeAssumeCapacity(0, n, &.{});
51 }
52 pub fn read(self: *Wire) !client.Incoming {
53 if (self.tr.link == .quic) return self.tr.readFrame(self.alloc);
54 var need: usize = proto.frame_header_len;
55 if (self.input.items.len >= proto.frame_header_len) {
56 const len = std.mem.readInt(u32, self.input.items[1..5], .little);
57 if (len > proto.max_payload) return error.FrameTooLarge;
58 need += len;
59 }
60 var buf: [64 * 1024]u8 = undefined;
61 const n = std.posix.read(self.tr.pollFd(), buf[0..@min(buf.len, need - self.input.items.len)]) catch |err| switch (err) {
62 error.WouldBlock => return .incomplete,
63 else => return err,
64 };
65 if (n == 0) return .closed;
66 try self.input.appendSlice(self.alloc, buf[0..n]);
67 if (try proto.takeFrame(self.alloc, &self.input)) |frame| return .{ .frame = frame };
68 return .incomplete;
69 }
70 };
71
72 fn nonblocking(fd: std.posix.fd_t) !void {
73 const flags = try std.posix.fcntl(fd, std.posix.F.GETFL, 0);
74 const bits: u32 = @bitCast(std.posix.O{ .NONBLOCK = true });
75 _ = try std.posix.fcntl(fd, std.posix.F.SETFL, flags | bits);
76 }
src/client/client.zig
Old New
@@ -34,6 +34,9 @@ pub const layoutfile = @import("layoutfile.zig");
34 pub const keymap = @import("keymap.zig"); 34 pub const keymap = @import("keymap.zig");
35 pub const askpass = @import("askpass.zig"); 35 pub const askpass = @import("askpass.zig");
36 pub const core = @import("client_core.zig"); 36 pub const core = @import("client_core.zig");
37 pub const discovery = @import("discovery.zig");
38 pub const resolver = @import("resolver.zig");
39 const open_wait = @import("open_wait.zig");
37 pub const session_pump = @import("session_pump.zig"); 40 pub const session_pump = @import("session_pump.zig");
38 41
39 /// A session name held by value. The names a switch travels on are decoded 42 /// A session name held by value. The names a switch travels on are decoded
@@ -443,14 +446,27 @@ pub const Transport = struct {
443 /// can be answered against the one dial it belongs to. 446 /// can be answered against the one dial it belongs to.
444 dial: ?*handoff.Dial, 447 dial: ?*handoff.Dial,
445 ) !Transport { 448 ) !Transport {
449 return openUntil(alloc, target, carry, abort_fd, dial, null);
450 }
451
452 /// An absolute budget shared by DNS, connect, handshake, SSH fallback and
453 /// the caller's later request. Null preserves the legacy opening policy.
454 pub fn openUntil(alloc: std.mem.Allocator, target: Target, carry: ?*std.ArrayList(u8), abort_fd: std.posix.fd_t, dial: ?*handoff.Dial, deadline: ?i64) !Transport {
455 var wait: open_wait.Wait = .{ .alloc = alloc, .abort_fd = abort_fd, .carry = carry, .deadline = deadline };
456 // Local first attach has always left stdin to the established client:
457 // even an already queued detach must attach before handling its chord.
458 // GUI callers have no carry and retain their dedicated cancel channel.
459 if (carry != null and (target == .sock or target == .via)) wait.abort_fd = -1;
460 if (target == .hand) try checkHandoffWait(&wait) else try wait.check();
446 switch (target) { 461 switch (target) {
447 // Delegated whole, because the handoff can end up producing 462 // Delegated whole, because the handoff can end up producing
448 // either of the two links below and owns the choice itself. 463 // either of the two links below and owns the choice itself.
449 .hand => |h| return openHandoff(alloc, h, carry, abort_fd, dial), 464 .hand => |h| return openHandoffWait(alloc, h, carry, abort_fd, dial, &wait),
450 .quic => |q| { 465 .quic => |q| {
451 const key = try quic.Key.load(q.key_path); 466 const key = try quic.Key.load(q.key_path);
452 const addr = try quic.parseAddr(alloc, q.host_port); 467 const hp = try quic.splitHostPort(q.host_port);
453 return quicTransport(alloc, addr, key, q.idle_ms, q.deadline_ms, carry, abort_fd); 468 const addr = try resolveOpening(alloc, hp.host, hp.port, q.deadline_ms, &wait);
469 return quicTransport(alloc, addr, key, q.idle_ms, try wait.remaining(q.deadline_ms), carry, abort_fd);
454 }, 470 },
455 .via => |cmd| { 471 .via => |cmd| {
456 const argv = try viaArgv(alloc, cmd); 472 const argv = try viaArgv(alloc, cmd);
@@ -458,7 +474,7 @@ pub const Transport = struct {
458 return pipeTransport(try spawnVia(alloc, argv)); 474 return pipeTransport(try spawnVia(alloc, argv));
459 }, 475 },
460 .sock => |path| { 476 .sock => |path| {
461 const stream = try dialer.dial(path); 477 const stream = try open_wait.connectUnix(path, &wait);
462 return .{ .link = .{ .fd = stream.handle } }; 478 return .{ .link = .{ .fd = stream.handle } };
463 }, 479 },
464 } 480 }
@@ -477,6 +493,19 @@ pub const Transport = struct {
477 abort_fd: std.posix.fd_t, 493 abort_fd: std.posix.fd_t,
478 dial: ?*handoff.Dial, 494 dial: ?*handoff.Dial,
479 ) !Transport { 495 ) !Transport {
496 var wait: open_wait.Wait = .{ .alloc = alloc, .abort_fd = abort_fd, .carry = carry };
497 return openHandoffWait(alloc, h, carry, abort_fd, dial, &wait);
498 }
499 fn checkHandoffWait(wait: *open_wait.Wait) !void {
500 // First-attach SSH owns cooked stdin for passwords. Only an actual
501 // QUIC wait may collect carry bytes; phase/deadline checks must not.
502 if (wait.carry != null) {
503 var deadline_only = wait.*;
504 deadline_only.abort_fd = -1;
505 try deadline_only.check();
506 } else try wait.check();
507 }
508 fn openHandoffWait(alloc: std.mem.Allocator, h: HandoffTarget, carry: ?*std.ArrayList(u8), abort_fd: std.posix.fd_t, dial: ?*handoff.Dial, wait: *open_wait.Wait) !Transport {
480 // The ORDER is `handoff.next`'s; this loop performs the step it is 509 // The ORDER is `handoff.next`'s; this loop performs the step it is
481 // handed and reports what came of it. 510 // handed and reports what came of it.
482 var st: handoff.State = .{ 511 var st: handoff.State = .{
@@ -489,7 +518,7 @@ pub const Transport = struct {
489 // so no step spells a kill of its own. 518 // so no step spells a kill of its own.
490 var child: ?std.process.Child = null; 519 var child: ?std.process.Child = null;
491 errdefer if (child) |*c| { 520 errdefer if (child) |*c| {
492 _ = c.kill() catch {}; 521 link_mod.terminateChild(c);
493 }; 522 };
494 // A caller with nowhere to show a reason still needs one kept: the 523 // A caller with nowhere to show a reason still needs one kept: the
495 // Transport carries it, and a failed dial's last line would 524 // Transport carries it, and a failed dial's last line would
@@ -506,6 +535,7 @@ pub const Transport = struct {
506 // is the port the fallback line names. 535 // is the port the fallback line names.
507 var dialed: ?handoff.Endpoint = null; 536 var dialed: ?handoff.Endpoint = null;
508 var quic_t: ?Transport = null; 537 var quic_t: ?Transport = null;
538 errdefer if (quic_t) |*transport| transport.close();
509 // `fail` returns the error the failing step actually got, so the 539 // `fail` returns the error the failing step actually got, so the
510 // caller's message names the real cause. Optional rather than a 540 // caller's message names the real cause. Optional rather than a
511 // placeholder value: a `fail` that nothing recorded is a hole in the 541 // placeholder value: a `fail` that nothing recorded is a hole in the
@@ -514,10 +544,11 @@ pub const Transport = struct {
514 544
515 var step = handoff.next(&st, null); 545 var step = handoff.next(&st, null);
516 while (true) { 546 while (true) {
547 try checkHandoffWait(wait);
517 const outcome: handoff.Outcome = switch (step) { 548 const outcome: handoff.Outcome = switch (step) {
518 .dial_quic => |ep| blk: { 549 .dial_quic => |ep| blk: {
519 dialed = ep; 550 dialed = ep;
520 if (openQuicEndpoint(alloc, h, ep, carry, abort_fd)) |t| { 551 if (openQuicEndpointWait(alloc, h, ep, carry, abort_fd, wait)) |t| {
521 quic_t = t; 552 quic_t = t;
522 break :blk .ok; 553 break :blk .ok;
523 } else |err| { 554 } else |err| {
@@ -558,7 +589,7 @@ pub const Transport = struct {
558 // attach the tty is still cooked, so Ctrl-C aborts the group; 589 // attach the tty is still cooked, so Ctrl-C aborts the group;
559 // the byte-read abort is only real on reconnect. 590 // the byte-read abort is only real on reconnect.
560 const announce_abort_fd: std.posix.fd_t = if (carry != null) -1 else abort_fd; 591 const announce_abort_fd: std.posix.fd_t = if (carry != null) -1 else abort_fd;
561 const got = readAnnounceAbortable(child.?.stdout.?.handle, alloc, null, announce_abort_fd, &errp) catch |err| { 592 const got = readAnnounceUntil(child.?.stdout.?.handle, alloc, null, announce_abort_fd, &errp, wait.deadline) catch |err| {
562 // ssh says why on its way out, and its stdout's EOF can 593 // ssh says why on its way out, and its stdout's EOF can
563 // be the same poll pass as the last of it: read what is 594 // be the same poll pass as the last of it: read what is
564 // left BEFORE this error becomes the caller's answer. 595 // left BEFORE this error becomes the caller's answer.
@@ -582,7 +613,7 @@ pub const Transport = struct {
582 .use_quic => { 613 .use_quic => {
583 // QUIC carries the session now, so the coordination ssh — 614 // QUIC carries the session now, so the coordination ssh —
584 // if this handoff ran one at all — is done. kill() 615 // if this handoff ran one at all — is done. kill()
585 // waitpid()s internally and closes the pipes with it; 616 // waits and closes the pipes with it;
586 // stderr is no longer among them, so it is closed here. 617 // stderr is no longer among them, so it is closed here.
587 errp.close(); 618 errp.close();
588 // ...and says nothing. Whatever the coordination ssh 619 // ...and says nothing. Whatever the coordination ssh
@@ -591,7 +622,7 @@ pub const Transport = struct {
591 // fails on this target, blaming a box that is up. 622 // fails on this target, blaming a box that is up.
592 errp.reason.clear(); 623 errp.reason.clear();
593 if (child) |*c| { 624 if (child) |*c| {
594 _ = c.kill() catch {}; 625 link_mod.terminateChild(c);
595 } 626 }
596 return quic_t.?; 627 return quic_t.?;
597 }, 628 },
@@ -628,9 +659,20 @@ pub const Transport = struct {
628 carry: ?*std.ArrayList(u8), 659 carry: ?*std.ArrayList(u8),
629 abort_fd: std.posix.fd_t, 660 abort_fd: std.posix.fd_t,
630 ) !Transport { 661 ) !Transport {
631 const addr = try quic.resolveHost(alloc, handoff.dialHost(h.host), ep.port); 662 var wait: open_wait.Wait = .{ .alloc = alloc, .abort_fd = abort_fd, .carry = carry };
663 return openQuicEndpointWait(alloc, h, ep, carry, abort_fd, &wait);
664 }
665 fn openQuicEndpointWait(alloc: std.mem.Allocator, h: HandoffTarget, ep: handoff.Endpoint, carry: ?*std.ArrayList(u8), abort_fd: std.posix.fd_t, wait: *open_wait.Wait) !Transport {
666 const addr = try resolveOpening(alloc, handoff.dialHost(h.host), ep.port, h.deadline_ms, wait);
632 const key = quic.Key{ .bytes = ep.key }; 667 const key = quic.Key{ .bytes = ep.key };
633 return quicTransport(alloc, addr, key, h.idle_ms, h.deadline_ms, carry, abort_fd); 668 return quicTransport(alloc, addr, key, h.idle_ms, try wait.remaining(h.deadline_ms), carry, abort_fd);
669 }
670 fn resolveOpening(alloc: std.mem.Allocator, host: []const u8, port: u16, budget_ms: u32, wait: *open_wait.Wait) !std.net.Address {
671 if (wait.abort_fd < 0 and wait.deadline == null) return quic.resolveHost(alloc, host, port);
672 const previous = wait.deadline;
673 defer wait.deadline = previous;
674 wait.deadline = @min(previous orelse std.math.maxInt(i64), std.time.milliTimestamp() + budget_ms);
675 return resolver.resolve(alloc, host, port, wait);
634 } 676 }
635 677
636 /// Not where frames come from: QUIC polls UDP, reads frames above it. 678 /// Not where frames come from: QUIC polls UDP, reads frames above it.
@@ -849,6 +891,9 @@ fn readAnnounceAbortable(
849 /// Null in the tests that drive this function off a bare fd. 891 /// Null in the tests that drive this function off a bare fd.
850 errp: ?*ErrPipe, 892 errp: ?*ErrPipe,
851 ) !?handoff.Endpoint { 893 ) !?handoff.Endpoint {
894 return readAnnounceUntil(fd, alloc, carry, abort_fd, errp, null);
895 }
896 fn readAnnounceUntil(fd: std.posix.fd_t, alloc: std.mem.Allocator, carry: ?*std.ArrayList(u8), abort_fd: std.posix.fd_t, errp: ?*ErrPipe, deadline: ?i64) !?handoff.Endpoint {
852 var buf: [handoff.announce_max_len]u8 = undefined; 897 var buf: [handoff.announce_max_len]u8 = undefined;
853 var n: usize = 0; 898 var n: usize = 0;
854 // A closed abort fd stays readable forever, so once it reports EOF it 899 // A closed abort fd stays readable forever, so once it reports EOF it
@@ -877,7 +922,10 @@ fn readAnnounceAbortable(
877 }; 922 };
878 // No timeout, per the note above. std.posix.poll retries EINTR 923 // No timeout, per the note above. std.posix.poll retries EINTR
879 // itself, so a SIGWINCH mid-wait is not an error to handle here. 924 // itself, so a SIGWINCH mid-wait is not an error to handle here.
880 _ = try std.posix.poll(&fds, -1); 925 const timeout: i32 = if (deadline) |end| @intCast(std.math.clamp(end - std.time.milliTimestamp(), 0, std.math.maxInt(i32))) else -1;
926 if (timeout == 0) return error.Timeout;
927 _ = try std.posix.poll(&fds, timeout);
928 if (deadline) |end| if (std.time.milliTimestamp() >= end) return error.Timeout;
881 929
882 // `openHandoff` always passes `carry` null: on a first attach this 930 // `openHandoff` always passes `carry` null: on a first attach this
883 // fd is -1 and never read — the keystrokes wait in the kernel's tty 931 // fd is -1 and never read — the keystrokes wait in the kernel's tty
@@ -1756,6 +1804,45 @@ test "Transport.open: a --via target yields a pipe, a --sock target an fd" {
1756 try std.testing.expect(s.link == .fd); 1804 try std.testing.expect(s.link == .fd);
1757 } 1805 }
1758 1806
1807 test "local first attach leaves preloaded detach input for the client while dedicated cancellation still aborts" {
1808 const alloc = std.testing.allocator;
1809 var tmp = try TmpDir.make();
1810 defer tmp.cleanup();
1811 const path = try std.fmt.allocPrint(alloc, "{s}/queued-input.sock", .{tmp.path()});
1812 defer alloc.free(path);
1813 const addr = try std.net.Address.initUnix(path);
1814 var listener = try addr.listen(.{});
1815 defer listener.deinit();
1816
1817 const targets = [_]Target{ .{ .sock = path }, .{ .via = "cat" } };
1818 for (targets) |target| {
1819 for ([_][]const u8{ "\x1cd", "\x1c\x1c", "ordinary input\n" }) |input| {
1820 var stdin = try FakeStdin.install(input);
1821 defer stdin.deinit();
1822 var carry: std.ArrayList(u8) = .empty;
1823 defer carry.deinit(alloc);
1824 var transport = try Transport.open(alloc, target, &carry, std.posix.STDIN_FILENO, null);
1825 defer transport.close();
1826 try std.testing.expectEqual(@as(usize, 0), carry.items.len);
1827 // Bound the read: a regression must fail instead of waiting for
1828 // bytes that opening mistakenly consumed.
1829 var fds = [_]std.posix.pollfd{.{ .fd = std.posix.STDIN_FILENO, .events = std.posix.POLL.IN, .revents = 0 }};
1830 try std.testing.expectEqual(@as(usize, 1), try std.posix.poll(&fds, 100));
1831 var bytes: [32]u8 = undefined;
1832 const n = try std.posix.read(std.posix.STDIN_FILENO, &bytes);
1833 try std.testing.expectEqualStrings(input, bytes[0..n]);
1834 }
1835
1836 // GUI pumps and discovery jobs have a dedicated channel, no carry.
1837 // Their preloaded cancellation must still stop opening immediately.
1838 const cancel = try std.posix.pipe();
1839 defer std.posix.close(cancel[0]);
1840 defer std.posix.close(cancel[1]);
1841 _ = try std.posix.write(cancel[1], "\x1c");
1842 try std.testing.expectError(error.UserAbort, Transport.open(alloc, target, null, cancel[0], null));
1843 }
1844 }
1845
1759 test "--via: the words reach the program verbatim — no shell splits, expands or quotes them" { 1846 test "--via: the words reach the program verbatim — no shell splits, expands or quotes them" {
1760 const alloc = std.testing.allocator; 1847 const alloc = std.testing.allocator;
1761 var tmp = try TmpDir.make(); 1848 var tmp = try TmpDir.make();
@@ -2903,6 +2990,9 @@ test "client: a wall spelling that does not fit fails rather than truncates" {
2903 // unreferenced decl must at least compile (the silent-module-loss hazard, 2990 // unreferenced decl must at least compile (the silent-module-loss hazard,
2904 // decisions.md). Pub decls only: std.meta.declarations sees nothing private. 2991 // decisions.md). Pub decls only: std.meta.declarations sees nothing private.
2905 test { 2992 test {
2993 _ = discovery;
2994 _ = resolver;
2995 _ = open_wait;
2906 std.testing.refAllDeclsRecursive(@This()); 2996 std.testing.refAllDeclsRecursive(@This());
2907 _ = @import("client_core.zig"); 2997 _ = @import("client_core.zig");
2908 _ = @import("hosts.zig"); 2998 _ = @import("hosts.zig");
src/client/discovery.zig
Old New
@@ -0,0 +1,295 @@
1 //! Cancellable picker requests. A Job owns its target, request, result and
2 //! thread until stop; notifications contain immutable request identity only.
3 const std = @import("std");
4 const client = @import("client.zig");
5 const proto = @import("term").protocol;
6 const Wait = @import("open_wait.zig").Wait;
7 const Wire = @import("buffered_wire.zig").Wire;
8 pub const Ticket = struct { generation: u64, owner: u64, attachment_generation: u64 };
9 pub const Operation = union(enum) { list, create: struct { name: []const u8, cols: u16, rows: u16 } };
10 pub const Phase = enum { working, sessions, created, exists, refused, cancelled, failed };
11 pub const Result = struct {
12 ticket: Ticket,
13 phase: Phase = .working,
14 /// Cancellation/timeout after dispatch cannot undo a remote creation.
15 may_have_created: bool = false,
16 bytes: [proto.sessions_reply_max]u8 = @splat(0),
17 len: usize = 0,
18 reason: [1024]u8 = @splat(0),
19 reason_len: usize = 0,
20 pub fn text(self: *const Result) []const u8 {
21 return self.bytes[0..self.len];
22 }
23 pub fn reasonText(self: *const Result) []const u8 {
24 return self.reason[0..self.reason_len];
25 }
26 };
27 pub const Options = struct {
28 ticket: Ticket,
29 target: client.Target,
30 operation: Operation = .list,
31 timeout_ms: u32 = 15000,
32 wake_ctx: ?*anyopaque = null,
33 wake: ?*const fn (?*anyopaque, Ticket) void = null,
34 };
35 pub const Job = struct {
36 alloc: std.mem.Allocator,
37 arena: std.heap.ArenaAllocator,
38 opts: Options,
39 cancel_pipe: [2]std.posix.fd_t,
40 mu: std.Thread.Mutex = .{},
41 result: Result,
42 thread: ?std.Thread = null,
43 done: std.atomic.Value(bool) = .init(false),
44
45 pub fn start(alloc: std.mem.Allocator, options: Options) !*Job {
46 var arena = std.heap.ArenaAllocator.init(alloc);
47 errdefer arena.deinit();
48 var opts = options;
49 const source = if (opts.operation == .list) try client.pollTargetFor(arena.allocator(), opts.target) else opts.target;
50 opts.target = try cloneTarget(arena.allocator(), source);
51 if (opts.operation == .create) {
52 const req = &opts.operation.create;
53 if (!proto.validSessionName(req.name) or req.cols < 2 or req.rows == 0 or req.cols > proto.max_cols) return error.InvalidCreate;
54 req.name = try arena.allocator().dupe(u8, req.name);
55 }
56 const pipe = try std.posix.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true });
57 errdefer for (pipe) |fd| std.posix.close(fd);
58 const self = try alloc.create(Job);
59 errdefer alloc.destroy(self);
60 self.* = .{ .alloc = alloc, .arena = arena, .opts = opts, .cancel_pipe = pipe, .result = .{ .ticket = opts.ticket } };
61 self.thread = try std.Thread.spawn(.{}, entry, .{self});
62 return self;
63 }
64 pub fn cancel(self: *Job) void {
65 _ = std.posix.write(self.cancel_pipe[1], &.{client.keymap.detach_key}) catch {};
66 }
67 /// Join before discarding this request or its callback context. Cancel is
68 /// independent of allocation and interrupts open, partial IO and reply waits.
69 pub fn stop(self: *Job) void {
70 self.cancel();
71 _ = self.join();
72 for (self.cancel_pipe) |fd| std.posix.close(fd);
73 self.arena.deinit();
74 self.alloc.destroy(self);
75 }
76 pub fn join(self: *Job) Result {
77 if (self.thread) |t| {
78 t.join();
79 self.thread = null;
80 }
81 return self.snapshot();
82 }
83 pub fn snapshot(self: *Job) Result {
84 self.mu.lock();
85 defer self.mu.unlock();
86 return self.result;
87 }
88 fn entry(self: *Job) void {
89 var out: Result = .{ .ticket = self.opts.ticket };
90 self.run(&out) catch |err| {
91 out.phase = if (err == error.UserAbort) .cancelled else .failed;
92 const reason = @errorName(err);
93 if (out.reason_len == 0) {
94 out.reason_len = @min(reason.len, out.reason.len);
95 @memcpy(out.reason[0..out.reason_len], reason[0..out.reason_len]);
96 }
97 };
98 self.mu.lock();
99 self.result = out;
100 self.mu.unlock();
101 self.done.store(true, .release);
102 if (self.opts.wake) |wake| wake(self.opts.wake_ctx, self.opts.ticket);
103 }
104 fn run(self: *Job, out: *Result) !void {
105 const end = std.time.milliTimestamp() + self.opts.timeout_ms;
106 var wait: Wait = .{ .alloc = self.alloc, .abort_fd = self.cancel_pipe[0], .deadline = end };
107 var dial: client.handoff.Dial = .{};
108 var tr = client.Transport.openUntil(self.alloc, self.opts.target, null, self.cancel_pipe[0], &dial, end) catch |err| {
109 var buf: [1024]u8 = undefined;
110 const failure = client.openFailure(&buf, self.opts.target, err, dial.reason.slice());
111 out.reason_len = @min(failure.msg.len, out.reason.len);
112 @memcpy(out.reason[0..out.reason_len], failure.msg[0..out.reason_len]);
113 return err;
114 };
115 defer tr.close();
116 var wire = try Wire.init(self.alloc, &tr);
117 defer wire.deinit();
118 var buf: [proto.create_req_max_len]u8 = undefined;
119 const kind: proto.MsgType = if (self.opts.operation == .list) .sessions_req else .create_req;
120 const payload = switch (self.opts.operation) {
121 .create => |req| proto.encodeCreateReq(&buf, req.cols, req.rows, req.name),
122 .list => "",
123 };
124 try wait.check();
125 // Conservative boundary: a partial send can commit remotely even if
126 // the local write later fails. Never turn cancellation into rollback.
127 out.may_have_created = self.opts.operation == .create;
128 try wire.send(kind, payload);
129 const want: proto.MsgType = if (kind == .sessions_req) .sessions_reply else .create_reply;
130 const frame = try awaitReply(&wire, &wait, want);
131 defer frame.deinit(self.alloc);
132 if (want == .sessions_reply) {
133 if (frame.payload.len > out.bytes.len) return error.BadPayload;
134 @memcpy(out.bytes[0..frame.payload.len], frame.payload);
135 out.len = frame.payload.len;
136 out.phase = .sessions;
137 } else {
138 const reply = try proto.parseCreateReply(frame.payload);
139 out.phase = switch (reply.status) {
140 .created => .created,
141 .exists => .exists,
142 .refused => .refused,
143 };
144 out.may_have_created = reply.status == .created;
145 out.reason_len = @min(reply.reason.len, out.reason.len);
146 @memcpy(out.reason[0..out.reason_len], reply.reason[0..out.reason_len]);
147 }
148 }
149 };
150 fn awaitReply(wire: *Wire, wait: *Wait, want: proto.MsgType) !proto.Frame {
151 while (true) {
152 try wait.check();
153 try wire.flush();
154 wire.tr.service();
155 // Stream reads are incremental/nonblocking. QUIC drains buffered
156 // frames eagerly, but always returns to cancellation between frames.
157 switch (try wire.read()) {
158 .closed => return error.Closed,
159 .frame => |frame| {
160 if (frame.type == want) return frame;
161 frame.deinit(wire.alloc);
162 continue;
163 },
164 .incomplete => {},
165 }
166 var fds = [_]std.posix.pollfd{
167 .{ .fd = wire.tr.pollFd(), .events = std.posix.POLL.IN, .revents = 0 },
168 .{ .fd = if (wire.tr.link != .quic and wire.pending()) wire.writeFd() else -1, .events = std.posix.POLL.OUT, .revents = 0 },
169 .{ .fd = wire.tr.errFd() orelse -1, .events = std.posix.POLL.IN, .revents = 0 },
170 };
171 try wait.poll(&fds, @intCast(wire.tr.timeoutMs(1000)));
172 if (fds[2].revents != 0) wire.tr.drainErr();
173 }
174 }
175
176 /// Deep target copy used by off-thread requests. The arena is owned by Job;
177 /// no nested argv/path/name slice borrows the caller's picker editor buffer.
178 pub fn cloneTarget(a: std.mem.Allocator, target: client.Target) !client.Target {
179 return switch (target) {
180 .sock => |s| .{ .sock = try a.dupe(u8, s) },
181 .via => |s| .{ .via = try a.dupe(u8, s) },
182 .quic => |q| blk: {
183 var copy = q;
184 copy.host_port = try a.dupe(u8, q.host_port);
185 copy.key_path = try a.dupe(u8, q.key_path);
186 break :blk .{ .quic = copy };
187 },
188 .hand => |h| blk: {
189 var copy = h;
190 copy.host = try a.dupe(u8, h.host);
191 copy.ssh_argv = try cloneArgv(a, h.ssh_argv);
192 copy.asked_argv = try cloneArgv(a, h.asked_argv);
193 if (h.cache_path) |v| copy.cache_path = try a.dupe(u8, v);
194 if (h.ask_sock) |v| copy.ask_sock = try a.dupe(u8, v);
195 copy.ask_exe = try a.dupe(u8, h.ask_exe);
196 break :blk .{ .hand = copy };
197 },
198 };
199 }
200 fn cloneArgv(a: std.mem.Allocator, argv: []const []const u8) ![]const []const u8 {
201 const copy = try a.alloc([]const u8, argv.len);
202 for (argv, copy) |s, *to| to.* = try a.dupe(u8, s);
203 return copy;
204 }
205
206 const TestPeer = struct {
207 tmp: @import("testtmp").TmpDir,
208 listener: std.net.Server,
209 path: []u8,
210 fn init() !TestPeer {
211 const a = std.testing.allocator;
212 var tmp = try @import("testtmp").TmpDir.make();
213 errdefer tmp.cleanup();
214 const path = try std.fmt.allocPrint(a, "{s}/picker.sock", .{tmp.path()});
215 errdefer a.free(path);
216 const addr = try std.net.Address.initUnix(path);
217 return .{ .tmp = tmp, .path = path, .listener = try addr.listen(.{}) };
218 }
219 fn deinit(self: *TestPeer) void {
220 self.listener.deinit();
221 std.testing.allocator.free(self.path);
222 self.tmp.cleanup();
223 }
224 fn accept(self: *TestPeer) !std.net.Stream {
225 var fds = [_]std.posix.pollfd{.{ .fd = self.listener.stream.handle, .events = std.posix.POLL.IN, .revents = 0 }};
226 if (try std.posix.poll(&fds, 2000) == 0) return error.AcceptTimeout;
227 return (try self.listener.accept()).stream;
228 }
229 };
230 fn resultOf(job: *Job) !Result {
231 const end = std.time.milliTimestamp() + 2000;
232 while (!job.done.load(.acquire)) {
233 if (std.time.milliTimestamp() >= end) return error.JobTimeout;
234 std.Thread.sleep(std.time.ns_per_ms);
235 }
236 return job.snapshot();
237 }
238 fn requestFrom(stream: std.net.Stream, expected: proto.MsgType) !void {
239 var link = client.Link{ .fd = stream.handle };
240 const frame = (try link.awaitFrame(std.testing.allocator, expected, 1000, .{})) orelse return error.RequestTimeout;
241 defer frame.deinit(std.testing.allocator);
242 }
243
244 test "picker retarget cancels partial reply and independent generation completes" {
245 const a = std.testing.allocator;
246 var peer = try TestPeer.init();
247 defer peer.deinit();
248 const source = try a.dupe(u8, peer.path);
249 defer a.free(source);
250 const old = try Job.start(a, .{ .ticket = .{ .generation = 1, .owner = 7, .attachment_generation = 2 }, .target = .{ .sock = source } });
251 defer old.stop();
252 @memset(source, 'x');
253 const first = try peer.accept();
254 defer first.close();
255 try requestFrom(first, .sessions_req);
256 try first.writeAll(&.{ @intFromEnum(proto.MsgType.sessions_reply), 20, 0 });
257 old.cancel();
258 const next = try Job.start(a, .{ .ticket = .{ .generation = 2, .owner = 9, .attachment_generation = 1 }, .target = .{ .sock = peer.path } });
259 defer next.stop();
260 const second = try peer.accept();
261 defer second.close();
262 try requestFrom(second, .sessions_req);
263 try proto.writeFrame(second.handle, .sessions_reply, "one\ntwo\n");
264 const fresh = try resultOf(next);
265 const stale = try resultOf(old);
266 try std.testing.expectEqual(Phase.sessions, fresh.phase);
267 try std.testing.expectEqualStrings("one\ntwo\n", fresh.text());
268 try std.testing.expectEqual(Phase.cancelled, stale.phase);
269 try std.testing.expect(!std.meta.eql(fresh.ticket, stale.ticket));
270 try std.testing.expect(!stale.may_have_created);
271 }
272
273 test "picker create uses explicit operation and reports ambiguous cancellation after dispatch" {
274 const a = std.testing.allocator;
275 var peer = try TestPeer.init();
276 defer peer.deinit();
277 for ([_]bool{ false, true }) |cancelled| {
278 const job = try Job.start(a, .{ .ticket = .{ .generation = 1, .owner = 1, .attachment_generation = 1 }, .target = .{ .sock = peer.path }, .operation = .{ .create = .{ .name = "0", .cols = 80, .rows = 24 } } });
279 defer job.stop();
280 const stream = try peer.accept();
281 defer stream.close();
282 var link = client.Link{ .fd = stream.handle };
283 const frame = (try link.awaitFrame(a, .create_req, 1000, .{})) orelse return error.RequestTimeout;
284 defer frame.deinit(a);
285 try std.testing.expectEqual(proto.MsgType.create_req, frame.type);
286 try std.testing.expectEqualStrings("0", (try proto.parseCreateReq(frame.payload)).name);
287 if (cancelled) job.cancel() else {
288 var buf: [proto.create_reply_max_len]u8 = undefined;
289 try proto.writeFrame(stream.handle, .create_reply, proto.encodeCreateReply(&buf, .exists, "name already exists"));
290 }
291 const result = try resultOf(job);
292 try std.testing.expectEqual(if (cancelled) Phase.cancelled else Phase.exists, result.phase);
293 try std.testing.expectEqual(cancelled, result.may_have_created);
294 }
295 }
src/client/open_wait.zig
Old New
@@ -0,0 +1,132 @@
1 //! Shared cancellation and absolute deadline for one opening/request operation.
2 const std = @import("std");
3 const keymap = @import("keymap.zig");
4 pub const Wait = struct {
5 alloc: std.mem.Allocator,
6 abort_fd: std.posix.fd_t = -1,
7 carry: ?*std.ArrayList(u8) = null,
8 deadline: ?i64 = null,
9 watching: bool = true,
10 pub fn check(self: *Wait) !void {
11 if (self.abort_fd >= 0 and self.watching) {
12 var fds = [_]std.posix.pollfd{.{ .fd = self.abort_fd, .events = std.posix.POLL.IN, .revents = 0 }};
13 _ = try std.posix.poll(&fds, 0);
14 if (fds[0].revents != 0) {
15 var buf: [256]u8 = undefined;
16 const n = std.posix.read(self.abort_fd, &buf) catch 0;
17 if (n == 0) self.watching = false;
18 if (std.mem.indexOfScalar(u8, buf[0..n], keymap.detach_key) != null) return error.UserAbort;
19 if (self.carry) |out| try out.appendSlice(self.alloc, buf[0..n]);
20 }
21 }
22 if (self.deadline) |end| if (std.time.milliTimestamp() >= end) return error.Timeout;
23 }
24 pub fn remaining(self: *Wait, cap: u32) !u32 {
25 try self.check();
26 return if (self.deadline) |end| @intCast(@min(@as(i64, cap), @max(0, end - std.time.milliTimestamp()))) else cap;
27 }
28 pub fn poll(self: *Wait, fds: []std.posix.pollfd, cap: u32) !void {
29 std.debug.assert(fds.len < 8);
30 var all: [8]std.posix.pollfd = undefined;
31 @memcpy(all[0..fds.len], fds);
32 all[fds.len] = .{ .fd = if (self.watching) self.abort_fd else -1, .events = std.posix.POLL.IN, .revents = 0 };
33 const ms = try self.remaining(cap);
34 _ = try std.posix.poll(all[0 .. fds.len + 1], @intCast(@min(ms, std.math.maxInt(i32))));
35 try self.check();
36 @memcpy(fds, all[0..fds.len]);
37 }
38 };
39
40 /// A full Unix listen queue gives EAGAIN on Linux, without initiating a
41 /// connection. Retry connect after a cancellable delay; writable/SO_ERROR=0
42 /// does not prove such a socket connected. Other pending connects use SO_ERROR.
43 pub fn connectUnix(path: []const u8, wait: *Wait) !std.net.Stream {
44 const addr = try std.net.Address.initUnix(path);
45 const fd = try std.posix.socket(std.posix.AF.UNIX, std.posix.SOCK.STREAM | std.posix.SOCK.CLOEXEC | std.posix.SOCK.NONBLOCK, 0);
46 errdefer std.posix.close(fd);
47 while (true) {
48 try wait.check();
49 switch (std.posix.errno(std.posix.system.connect(fd, &addr.any, addr.getOsSockLen()))) {
50 .SUCCESS, .ISCONN => {},
51 .INTR => continue,
52 .AGAIN => {
53 var none: [0]std.posix.pollfd = .{};
54 try wait.poll(&none, 10);
55 continue;
56 },
57 .INPROGRESS, .ALREADY => {
58 var fds = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.OUT, .revents = 0 }};
59 while (fds[0].revents == 0) try wait.poll(&fds, 1000);
60 try std.posix.getsockoptError(fd);
61 },
62 .NOENT => return error.FileNotFound,
63 .ACCES => return error.AccessDenied,
64 .PERM => return error.PermissionDenied,
65 .NOMEM, .NOBUFS => return error.SystemResources,
66 else => return error.ConnectionRefused,
67 }
68 break;
69 }
70 try wait.check();
71 const flags = try std.posix.fcntl(fd, std.posix.F.GETFL, 0);
72 const bits: u32 = @bitCast(std.posix.O{ .NONBLOCK = true });
73 _ = try std.posix.fcntl(fd, std.posix.F.SETFL, flags & ~@as(usize, bits));
74 return .{ .handle = fd };
75 }
76
77 test "full Unix listen backlog remains cancellable and restores blocking only after connect" {
78 if (@import("builtin").os.tag != .linux) return error.SkipZigTest;
79 const a = std.testing.allocator;
80 var tmp = try @import("testtmp").TmpDir.make();
81 defer tmp.cleanup();
82 const path = try std.fmt.allocPrint(a, "{s}/full.sock", .{tmp.path()});
83 defer a.free(path);
84 const addr = try std.net.Address.initUnix(path);
85 var server = try addr.listen(.{ .kernel_backlog = 1 });
86 defer server.deinit();
87 var queued: std.ArrayList(std.posix.fd_t) = .empty;
88 defer {
89 for (queued.items) |fd| std.posix.close(fd);
90 queued.deinit(a);
91 }
92 var full = false;
93 for (0..16) |_| {
94 const fd = try std.posix.socket(std.posix.AF.UNIX, std.posix.SOCK.STREAM | std.posix.SOCK.NONBLOCK | std.posix.SOCK.CLOEXEC, 0);
95 const err = std.posix.errno(std.posix.system.connect(fd, &addr.any, addr.getOsSockLen()));
96 if (err == .AGAIN) {
97 std.posix.close(fd);
98 full = true;
99 break;
100 }
101 if (err != .SUCCESS) {
102 std.posix.close(fd);
103 return error.UnexpectedConnect;
104 }
105 try queued.append(a, fd);
106 }
107 try std.testing.expect(full);
108 var wait: Wait = .{ .alloc = a, .deadline = std.time.milliTimestamp() + 30 };
109 try std.testing.expectError(error.Timeout, connectUnix(path, &wait));
110 const pipe = try std.posix.pipe2(.{ .CLOEXEC = true });
111 defer for (pipe) |fd| std.posix.close(fd);
112 const Cancel = struct {
113 fn fire(fd: std.posix.fd_t) void {
114 std.Thread.sleep(20 * std.time.ns_per_ms);
115 _ = std.posix.write(fd, &.{keymap.detach_key}) catch {};
116 }
117 };
118 const thread = try std.Thread.spawn(.{}, Cancel.fire, .{pipe[1]});
119 defer thread.join();
120 wait = .{ .alloc = a, .abort_fd = pipe[0], .deadline = std.time.milliTimestamp() + 2000 };
121 const start = std.time.milliTimestamp();
122 try std.testing.expectError(error.UserAbort, connectUnix(path, &wait));
123 try std.testing.expect(std.time.milliTimestamp() - start < 500);
124 const accepted = try server.accept();
125 accepted.stream.close();
126 wait = .{ .alloc = a, .deadline = std.time.milliTimestamp() + 500 };
127 const stream = try connectUnix(path, &wait);
128 defer stream.close();
129 const flags = try std.posix.fcntl(stream.handle, std.posix.F.GETFL, 0);
130 const bits: u32 = @bitCast(std.posix.O{ .NONBLOCK = true });
131 try std.testing.expect(flags & bits == 0);
132 }
src/client/resolver.zig
Old New
@@ -0,0 +1,92 @@
1 //! DNS lives in an exec'd helper when opening is cancellable. Blocking libc
2 //! resolution cannot outlive the request: cancellation kills and reaps the
3 //! helper, without cancelling a thread or using libc after a multithreaded fork.
4 const std = @import("std");
5 const quic = @import("quic");
6 const Wait = @import("open_wait.zig").Wait;
7 const flag = "--internal-resolve";
8 const result_len = 21;
9
10 /// Dispatch before ordinary CLI parsing. The child has no inherited terminal
11 /// input and writes one bounded address record, never daemon protocol frames.
12 pub fn helper(alloc: std.mem.Allocator, args: []const []const u8) !?u8 {
13 if (args.len == 0 or !std.mem.eql(u8, args[0], flag)) return null;
14 if (args.len != 3 or args[1].len == 0 or args[1].len > 1024) return 2;
15 const port = std.fmt.parseInt(u16, args[2], 10) catch return 2;
16 const addr = quic.resolveHost(alloc, args[1], port) catch return 1;
17 var bytes: [result_len]u8 = @splat(0);
18 switch (addr.any.family) {
19 std.posix.AF.INET => {
20 bytes[0] = 4;
21 @memcpy(bytes[1..5], std.mem.asBytes(&addr.in.sa.addr));
22 },
23 std.posix.AF.INET6 => {
24 bytes[0] = 6;
25 @memcpy(bytes[1..17], &addr.in6.sa.addr);
26 std.mem.writeInt(u32, bytes[17..21], addr.in6.sa.scope_id, .little);
27 },
28 else => return 1,
29 }
30 try std.fs.File.stdout().writeAll(&bytes);
31 return 0;
32 }
33
34 pub fn resolve(alloc: std.mem.Allocator, host: []const u8, port: u16, wait: *Wait) !std.net.Address {
35 try wait.check();
36 if (host.len == 0 or host.len > 1024) return error.MalformedAddress;
37 if (std.net.Address.parseIp(host, port)) |addr| return addr else |_| {}
38 const exe = try std.fs.selfExePathAlloc(alloc);
39 defer alloc.free(exe);
40 var port_buf: [5]u8 = undefined;
41 return resolveChild(alloc, &.{ exe, flag, host, try std.fmt.bufPrint(&port_buf, "{d}", .{port}) }, port, wait);
42 }
43 fn resolveChild(alloc: std.mem.Allocator, argv: []const []const u8, port: u16, wait: *Wait) !std.net.Address {
44 try wait.check();
45 var child = std.process.Child.init(argv, alloc);
46 child.stdin_behavior = .Ignore;
47 child.stdout_behavior = .Pipe;
48 child.stderr_behavior = .Ignore;
49 try child.spawn();
50 defer @import("link").terminateChild(&child);
51 var bytes: [result_len]u8 = undefined;
52 var used: usize = 0;
53 while (used < bytes.len) {
54 var fds = [_]std.posix.pollfd{.{ .fd = child.stdout.?.handle, .events = std.posix.POLL.IN, .revents = 0 }};
55 try wait.poll(&fds, 1000);
56 if (fds[0].revents == 0) continue;
57 const n = try std.posix.read(child.stdout.?.handle, bytes[used..]);
58 if (n == 0) return error.UnknownHostName;
59 used += n;
60 }
61 try wait.check();
62 return switch (bytes[0]) {
63 4 => std.net.Address.initIp4(bytes[1..5].*, port),
64 6 => std.net.Address.initIp6(bytes[1..17].*, port, 0, std.mem.readInt(u32, bytes[17..21], .little)),
65 else => error.UnknownHostName,
66 };
67 }
68
69 test "DNS helper wait is deadline bounded and numeric addresses do not spawn" {
70 var wait: Wait = .{ .alloc = std.testing.allocator, .deadline = std.time.milliTimestamp() + 40 };
71 try std.testing.expectError(error.Timeout, resolveChild(std.testing.allocator, &.{ "sleep", "30" }, 4433, &wait));
72 wait.deadline = std.time.milliTimestamp() + 100;
73 const addr = try resolve(std.testing.allocator, "127.0.0.1", 4433, &wait);
74 try std.testing.expectEqual(@as(u16, 4433), addr.getPort());
75 }
76
77 test "cancel fd interrupts and reaps a stalled resolver child" {
78 const pipe = try std.posix.pipe2(.{ .CLOEXEC = true });
79 defer for (pipe) |fd| std.posix.close(fd);
80 const Cancel = struct {
81 fn fire(fd: std.posix.fd_t) void {
82 std.Thread.sleep(20 * std.time.ns_per_ms);
83 _ = std.posix.write(fd, &.{@import("keymap.zig").detach_key}) catch {};
84 }
85 };
86 const thread = try std.Thread.spawn(.{}, Cancel.fire, .{pipe[1]});
87 defer thread.join();
88 var wait: Wait = .{ .alloc = std.testing.allocator, .abort_fd = pipe[0], .deadline = std.time.milliTimestamp() + 2000 };
89 const start = std.time.milliTimestamp();
90 try std.testing.expectError(error.UserAbort, resolveChild(std.testing.allocator, &.{ "sleep", "30" }, 4433, &wait));
91 try std.testing.expect(std.time.milliTimestamp() - start < 500);
92 }
src/client/session_pump.zig
Old New
@@ -6,6 +6,7 @@ const std = @import("std");
6 const client = @import("client.zig"); 6 const client = @import("client.zig");
7 const term = @import("term"); 7 const term = @import("term");
8 const proto = term.protocol; 8 const proto = term.protocol;
9 const Wire = @import("buffered_wire.zig").Wire;
9 10
10 pub const Say = union(enum) { input: []const u8, resize: proto.Size, detach, quit }; 11 pub const Say = union(enum) { input: []const u8, resize: proto.Size, detach, quit };
11 pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed, dial_failed }; 12 pub const Phase = enum { dialing, attached, reconnecting, exited, refused, taken, failed, dial_failed };
@@ -26,6 +27,8 @@ pub const Options = struct {
26 session: []const u8 = "0", 27 session: []const u8 = "0",
27 cols: u16, 28 cols: u16,
28 rows: u16, 29 rows: u16,
30 /// Join only: never create a vanished session, including on reconnect.
31 existing_only: bool = false,
29 wake: ?*const fn (?*anyopaque) void = null, 32 wake: ?*const fn (?*anyopaque) void = null,
30 wake_ctx: ?*anyopaque = null, 33 wake_ctx: ?*anyopaque = null,
31 }; 34 };
@@ -45,6 +48,7 @@ pub const Pump = struct {
45 cancel_pipe: [2]std.posix.fd_t, 48 cancel_pipe: [2]std.posix.fd_t,
46 closing: std.atomic.Value(bool) = .init(false), 49 closing: std.atomic.Value(bool) = .init(false),
47 thread: ?std.Thread = null, 50 thread: ?std.Thread = null,
51 admitted: bool = false,
48 52
49 pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump { 53 pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump {
50 if (opts.session.len != 0 and !proto.validSessionName(opts.session)) return error.InvalidSession; 54 if (opts.session.len != 0 and !proto.validSessionName(opts.session)) return error.InvalidSession;
@@ -174,8 +178,9 @@ pub const Pump = struct {
174 const args = self.replica.attachArgs(); 178 const args = self.replica.attachArgs();
175 self.replica.state_since_attach = false; 179 self.replica.state_since_attach = false;
176 self.mu.unlock(); 180 self.mu.unlock();
181 self.admitted = false;
177 var buf: [proto.attach_max_len]u8 = undefined; 182 var buf: [proto.attach_max_len]u8 = undefined;
178 try wire.send(.attach, proto.encodeAttachNamed(&buf, self.opts.cols, self.opts.rows, if (fresh) 0 else args.have_seq, if (fresh) 0 else args.have_epoch, proto.wireName(self.opts.session))); 183 try wire.send(.attach, proto.encodeAttachNamed(&buf, if (self.opts.existing_only) 0 else self.opts.cols, if (self.opts.existing_only) 0 else self.opts.rows, if (fresh) 0 else args.have_seq, if (fresh) 0 else args.have_epoch, proto.wireName(self.opts.session)));
179 } 184 }
180 185
181 fn mail(self: *Pump, wire: *Wire) !void { 186 fn mail(self: *Pump, wire: *Wire) !void {
@@ -193,8 +198,10 @@ pub const Pump = struct {
193 .resize => |size| { 198 .resize => |size| {
194 self.opts.cols = size.cols; 199 self.opts.cols = size.cols;
195 self.opts.rows = size.rows; 200 self.opts.rows = size.rows;
196 const buf = proto.encodeSize(size.cols, size.rows); 201 if (!self.opts.existing_only or self.admitted) {
197 try wire.send(.resize, &buf); 202 const buf = proto.encodeSize(size.cols, size.rows);
203 try wire.send(.resize, &buf);
204 }
198 }, 205 },
199 .quit, .detach => unreachable, 206 .quit, .detach => unreachable,
200 }; 207 };
@@ -241,6 +248,13 @@ pub const Pump = struct {
241 .frame => |frame| { 248 .frame => |frame| {
242 defer frame.deinit(self.alloc); 249 defer frame.deinit(self.alloc);
243 const action = try self.onFrame(frame.type, frame.payload); 250 const action = try self.onFrame(frame.type, frame.payload);
251 if (!self.admitted and (frame.type == .snapshot or frame.type == .delta) and action == .changed) {
252 self.admitted = true;
253 if (self.opts.existing_only) {
254 const size = proto.encodeSize(self.opts.cols, self.opts.rows);
255 try wire.send(.resize, &size);
256 }
257 }
244 changed = changed or action != .skip; 258 changed = changed or action != .skip;
245 switch (action) { 259 switch (action) {
246 .resync => try self.attach(wire, true), 260 .resync => try self.attach(wire, true),
@@ -293,74 +307,6 @@ pub const Pump = struct {
293 } 307 }
294 }; 308 };
295 309
296 // Incremental reads and queued writes keep partial stream frames and a
297 // peer which stops reading from blocking the mailbox or stop(). QUIC keeps
298 // its existing framing and outgoing queue in Link.
299 const Wire = struct {
300 alloc: std.mem.Allocator,
301 tr: *client.Transport,
302 input: std.ArrayList(u8) = .empty,
303 output: std.ArrayList(u8) = .empty,
304
305 fn init(alloc: std.mem.Allocator, tr: *client.Transport) !Wire {
306 switch (tr.link) {
307 .fd => |fd| try nonblocking(fd),
308 .pipe => |p| {
309 try nonblocking(p.r);
310 try nonblocking(p.w);
311 },
312 .quic => {},
313 }
314 return .{ .alloc = alloc, .tr = tr };
315 }
316 fn deinit(self: *Wire) void {
317 self.input.deinit(self.alloc);
318 self.output.deinit(self.alloc);
319 }
320 fn writeFd(self: *Wire) std.posix.fd_t {
321 return switch (self.tr.link) {
322 .fd => |fd| fd,
323 .pipe => |p| p.w,
324 .quic => self.tr.pollFd(),
325 };
326 }
327 fn pending(self: *Wire) bool {
328 return if (self.tr.link == .quic) self.tr.link.quic.qout.items.len > 0 else self.output.items.len > 0;
329 }
330 fn send(self: *Wire, kind: proto.MsgType, payload: []const u8) !void {
331 if (self.tr.link == .quic) return self.tr.writeFrame(kind, payload);
332 try proto.appendFrame(&self.output, self.alloc, kind, payload);
333 try self.flush();
334 }
335 fn flush(self: *Wire) !void {
336 if (self.tr.link == .quic) return self.tr.flushQuic();
337 if (self.output.items.len == 0) return;
338 const n = std.posix.write(self.writeFd(), self.output.items) catch |err| switch (err) {
339 error.WouldBlock => return,
340 else => return err,
341 };
342 self.output.replaceRangeAssumeCapacity(0, n, &.{});
343 }
344 fn read(self: *Wire) !client.Incoming {
345 if (self.tr.link == .quic) return self.tr.readFrame(self.alloc);
346 var need: usize = proto.frame_header_len;
347 if (self.input.items.len >= proto.frame_header_len) {
348 const len = std.mem.readInt(u32, self.input.items[1..5], .little);
349 if (len > proto.max_payload) return error.FrameTooLarge;
350 need += len;
351 }
352 var buf: [64 * 1024]u8 = undefined;
353 const n = std.posix.read(self.tr.pollFd(), buf[0..@min(buf.len, need - self.input.items.len)]) catch |err| switch (err) {
354 error.WouldBlock => return .incomplete,
355 else => return err,
356 };
357 if (n == 0) return .closed;
358 try self.input.appendSlice(self.alloc, buf[0..n]);
359 if (try proto.takeFrame(self.alloc, &self.input)) |frame| return .{ .frame = frame };
360 return .incomplete;
361 }
362 };
363
364 // Only a lost connection earns a redial. Resource exhaustion, poll errors, 310 // Only a lost connection earns a redial. Resource exhaustion, poll errors,
365 // and other local failures must reach entry's failure publication. 311 // and other local failures must reach entry's failure publication.
366 fn connectionFailure(err: anyerror) anyerror!bool { 312 fn connectionFailure(err: anyerror) anyerror!bool {
@@ -370,11 +316,6 @@ fn connectionFailure(err: anyerror) anyerror!bool {
370 }; 316 };
371 } 317 }
372 318
373 fn nonblocking(fd: std.posix.fd_t) !void {
374 const flags = try std.posix.fcntl(fd, std.posix.F.GETFL, 0);
375 const bits: u32 = @bitCast(std.posix.O{ .NONBLOCK = true });
376 _ = try std.posix.fcntl(fd, std.posix.F.SETFL, flags | bits);
377 }
378 fn closePipe(fds: [2]std.posix.fd_t) void { 319 fn closePipe(fds: [2]std.posix.fd_t) void {
379 for (fds) |fd| std.posix.close(fd); 320 for (fds) |fd| std.posix.close(fd);
380 } 321 }
@@ -755,3 +696,44 @@ test "session pump pipe snapshot paints then remains responsive while idle" {
755 stopped = true; 696 stopped = true;
756 try std.testing.expect(std.time.milliTimestamp() - start < 500); 697 try std.testing.expect(std.time.milliTimestamp() - start < 500);
757 } 698 }
699
700 test "existing-only pump never sends a create-size attach or pre-admission resize" {
701 var peer = try TestPeer.init();
702 defer peer.deinit();
703 const pump = try Pump.start(std.testing.allocator, .{ .target = .{ .sock = peer.path }, .session = "native-test", .cols = 11, .rows = 3, .existing_only = true });
704 defer pump.stop();
705 {
706 const stream = try peer.accept();
707 defer stream.close();
708 const attach_frame = try testFrame(stream, .attach);
709 defer attach_frame.deinit(std.testing.allocator);
710 const args = try proto.decodeAttach(attach_frame.payload);
711 try std.testing.expectEqual(@as(u16, 0), args.cols);
712 try std.testing.expectEqual(@as(u16, 0), args.rows);
713 try pump.say(.{ .resize = .{ .cols = 19, .rows = 7 } });
714 var fds = [_]std.posix.pollfd{.{ .fd = stream.handle, .events = std.posix.POLL.IN, .revents = 0 }};
715 try std.testing.expectEqual(@as(usize, 0), try std.posix.poll(&fds, 30));
716 try proto.writeFrame(stream.handle, .snapshot, &testSnapshot());
717 const resize = try testFrame(stream, .resize);
718 defer resize.deinit(std.testing.allocator);
719 try std.testing.expectEqual(proto.Size{ .cols = 19, .rows = 7 }, try proto.decodeSize(resize.payload));
720 _ = try testPhase(pump, .attached);
721 }
722 const stream = try peer.accept();
723 defer stream.close();
724 const again = try testFrame(stream, .attach);
725 defer again.deinit(std.testing.allocator);
726 const args = try proto.decodeAttach(again.payload);
727 try std.testing.expectEqual(@as(u16, 0), args.cols);
728 try std.testing.expectEqual(@as(u16, 0), args.rows);
729 try std.testing.expectEqual(@as(u64, 37), args.have_seq);
730 try pump.say(.{ .resize = .{ .cols = 23, .rows = 9 } });
731 // An old daemon's ordinary missing-session refusal stays a refusal;
732 // queued resize cannot resurrect the name between list and reconnect.
733 try proto.writeFrame(stream.handle, .exit_status, &.{1});
734 _ = try testPhase(pump, .refused);
735 var fds = [_]std.posix.pollfd{.{ .fd = stream.handle, .events = std.posix.POLL.IN, .revents = 0 }};
736 _ = try std.posix.poll(&fds, 100);
737 var bytes: [8]u8 = undefined;
738 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(stream.handle, &bytes));
739 }
src/engine/protocol.zig
Old New
@@ -23,6 +23,7 @@ pub const MsgType = enum(u8) {
23 agent_close = 0x0f, // payload: u32 LE channel id; BOTH directions 23 agent_close = 0x0f, // payload: u32 LE channel id; BOTH directions
24 upgrade_req = 0x10, // payload: u8 flags (bit0 allow_same_version) ++ version bytes ++ NUL ++ absolute path bytes 24 upgrade_req = 0x10, // payload: u8 flags (bit0 allow_same_version) ++ version bytes ++ NUL ++ absolute path bytes
25 end_req = 0x11, // payload: u8 flags (bit0 force) ++ optional session-name tail (empty = default session) 25 end_req = 0x11, // payload: u8 flags (bit0 force) ++ optional session-name tail (empty = default session)
26 create_req = 0x12, // payload: u16 LE cols, u16 LE rows ++ explicit session name; create only, never attach
26 debug_dump = 0x7f, // payload: 1 byte: 0 = plain, 1 = vt ++ optional session-name tail (empty = default session) 27 debug_dump = 0x7f, // payload: 1 byte: 0 = plain, 1 = vt ++ optional session-name tail (empty = default session)
27 // daemon -> client 28 // daemon -> client
28 // The three replay frames were renumbered in 2026-09 when their payloads 29 // The three replay frames were renumbered in 2026-09 when their payloads
@@ -52,6 +53,7 @@ pub const MsgType = enum(u8) {
52 agent_open = 0x92, // payload: u32 LE channel id; daemon allocates ids, only the daemon opens 53 agent_open = 0x92, // payload: u32 LE channel id; daemon allocates ids, only the daemon opens
53 upgrade_reply = 0x93, // payload: u8 status (0 accepted, 1 refused) ++ reason text 54 upgrade_reply = 0x93, // payload: u8 status (0 accepted, 1 refused) ++ reason text
54 end_reply = 0x94, // payload: u8 status (0 accepted, 1 refused) ++ u8 others ++ reason text 55 end_reply = 0x94, // payload: u8 status (0 accepted, 1 refused) ++ u8 others ++ reason text
56 create_reply = 0x98, // payload: CreateStatus byte ++ bounded reason text
55 dump_reply = 0xff, // payload: requested dump bytes 57 dump_reply = 0xff, // payload: requested dump bytes
56 _, 58 _,
57 }; 59 };
@@ -1103,6 +1105,64 @@ pub fn encodeDebugDumpNamed(
1103 return buf[0 .. 1 + name.len]; 1105 return buf[0 .. 1 + name.len];
1104 } 1106 }
1105 1107
1108 /// Explicit creation is separate from legacy attach-or-create. An unknown
1109 /// request on an older daemon has no side effect; callers must never fall
1110 /// back to attaching with a size after a timeout.
1111 pub const create_req_len = 4;
1112 pub const create_req_max_len = create_req_len + session_name_max;
1113 pub const create_reply_max_len = 129;
1114 pub const CreateStatus = enum(u8) { created = 0, exists = 1, refused = 2 };
1115 pub const CreateReq = struct { cols: u16, rows: u16, name: []const u8 };
1116 pub const CreateReply = struct { status: CreateStatus, reason: []const u8 };
1117
1118 pub fn encodeCreateReq(buf: *[create_req_max_len]u8, cols: u16, rows: u16, name: []const u8) []const u8 {
1119 std.debug.assert(validSessionName(name));
1120 std.mem.writeInt(u16, buf[0..2], cols, .little);
1121 std.mem.writeInt(u16, buf[2..4], rows, .little);
1122 @memcpy(buf[4..][0..name.len], name);
1123 return buf[0 .. 4 + name.len];
1124 }
1125
1126 pub fn parseCreateReq(payload: []const u8) error{BadCreate}!CreateReq {
1127 if (payload.len <= create_req_len or payload.len > create_req_max_len) return error.BadCreate;
1128 const name = payload[4..];
1129 if (!validSessionName(name)) return error.BadCreate;
1130 return .{ .cols = std.mem.readInt(u16, payload[0..2], .little), .rows = std.mem.readInt(u16, payload[2..4], .little), .name = name };
1131 }
1132
1133 pub fn encodeCreateReply(buf: *[create_reply_max_len]u8, status: CreateStatus, reason: []const u8) []const u8 {
1134 std.debug.assert(reason.len < create_reply_max_len);
1135 buf[0] = @intFromEnum(status);
1136 @memcpy(buf[1..][0..reason.len], reason);
1137 return buf[0 .. 1 + reason.len];
1138 }
1139
1140 pub fn parseCreateReply(payload: []const u8) error{BadCreate}!CreateReply {
1141 if (payload.len == 0 or payload.len > create_reply_max_len) return error.BadCreate;
1142 const status = std.enums.fromInt(CreateStatus, payload[0]) orelse return error.BadCreate;
1143 for (payload[1..]) |byte| if (byte < 0x20 or byte > 0x7e) return error.BadCreate;
1144 return .{ .status = status, .reason = payload[1..] };
1145 }
1146
1147 test "explicit creation rejects malformed names and reply status without aliasing default" {
1148 var req: [create_req_max_len]u8 = undefined;
1149 const parsed = try parseCreateReq(encodeCreateReq(&req, 80, 24, "work"));
1150 try std.testing.expectEqualStrings("work", parsed.name);
1151 try std.testing.expectEqual(@as(u16, 80), parsed.cols);
1152 try std.testing.expectEqual(@as(u16, 24), parsed.rows);
1153 try std.testing.expectEqualStrings("0", (try parseCreateReq(encodeCreateReq(&req, 80, 24, "0"))).name);
1154 try std.testing.expectError(error.BadCreate, parseCreateReq(&.{ 80, 0, 24, 0 }));
1155 try std.testing.expectError(error.BadCreate, parseCreateReq(&.{ 80, 0, 24, 0, '#' }));
1156 try std.testing.expectError(error.BadCreate, parseCreateReq(&.{ 80, 0, 24 }));
1157 var reply: [create_reply_max_len]u8 = undefined;
1158 const answer = try parseCreateReply(encodeCreateReply(&reply, .exists, "name in use"));
1159 try std.testing.expectEqual(CreateStatus.exists, answer.status);
1160 try std.testing.expectEqualStrings("name in use", answer.reason);
1161 try std.testing.expectError(error.BadCreate, parseCreateReply(&.{}));
1162 try std.testing.expectError(error.BadCreate, parseCreateReply(&.{3}));
1163 try std.testing.expectError(error.BadCreate, parseCreateReply(&.{ 2, 0x1b }));
1164 }
1165
1106 pub const end_req_len = 1; 1166 pub const end_req_len = 1;
1107 pub const end_req_max_len = end_req_len + session_name_max; 1167 pub const end_req_max_len = end_req_len + session_name_max;
1108 1168
src/gui/frame.zig
Old New
@@ -5,6 +5,7 @@ const term = @import("term");
5 const keymap = client.keymap; 5 const keymap = client.keymap;
6 const model = @import("workspace.zig"); 6 const model = @import("workspace.zig");
7 const runtime = @import("runtime.zig"); 7 const runtime = @import("runtime.zig");
8 const picker_mod = @import("picker.zig");
8 const font = @import("font.zig"); 9 const font = @import("font.zig");
9 const atlas = @import("atlas.zig"); 10 const atlas = @import("atlas.zig");
10 const quads = @import("quads.zig"); 11 const quads = @import("quads.zig");
@@ -17,7 +18,7 @@ const c = @cImport({
17 18
18 pub const Options = struct { 19 pub const Options = struct {
19 target: client.Target, 20 target: client.Target,
20 next: ?struct { target: client.Target, session: []const u8 } = null, 21 key_path: ?[]const u8 = null,
21 session: []const u8, 22 session: []const u8,
22 /// Face pixel size at 100% display scale. 23 /// Face pixel size at 100% display scale.
23 font_px: u16 = 16, 24 font_px: u16 = 16,
@@ -130,6 +131,15 @@ const Wake = struct {
130 // strand an attachment. The queue holds IDs, never freed Live pointers. 131 // strand an attachment. The queue holds IDs, never freed Live pointers.
131 _ = c.SDL_PushEvent(&ev); 132 _ = c.SDL_PushEvent(&ev);
132 } 133 }
134 fn discovery(ctx: ?*anyopaque, ticket: client.discovery.Ticket) void {
135 const self: *Wake = @ptrCast(@alignCast(ctx.?));
136 var ev = std.mem.zeroes(c.SDL_Event);
137 ev.type = self.event_type;
138 ev.user.code = 1;
139 ev.user.data1 = @ptrFromInt(ticket.generation);
140 ev.user.data2 = @ptrFromInt(ticket.owner);
141 _ = c.SDL_PushEvent(&ev);
142 }
133 }; 143 };
134 144
135 /// The optional test FIFO is read without blocking on the window thread. 145 /// The optional test FIFO is read without blocking on the window thread.
@@ -250,7 +260,9 @@ const Events = struct {
250 hook: ?*HookReader, 260 hook: ?*HookReader,
251 cache: *font.GlyphCache, 261 cache: *font.GlyphCache,
252 base_font_px: u16, 262 base_font_px: u16,
253 staged: ?model.Identity = null, 263 picker: ?*picker_mod.Picker = null,
264 next_request: u64 = 1,
265 key_path: ?[]const u8 = null,
254 layout: model.Layout = .{}, 266 layout: model.Layout = .{},
255 metrics: model.Metrics, 267 metrics: model.Metrics,
256 fb_w: c_int, 268 fb_w: c_int,
@@ -275,30 +287,37 @@ const Events = struct {
275 c.SDLK_B => ws.arm(.stacked), 287 c.SDLK_B => ws.arm(.stacked),
276 c.SDLK_ESCAPE => ws.cancel(), 288 c.SDLK_ESCAPE => ws.cancel(),
277 c.SDLK_RETURN, c.SDLK_KP_ENTER => { 289 c.SDLK_RETURN, c.SDLK_KP_ENTER => {
278 if (ws.tab().pending != null) { 290 self.picker = picker_mod.Picker.init(self.rt.alloc, self.rt, &self.next_request, self.key_path, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics, self.wake, Wake.discovery) catch |err| {
279 if (self.staged) |*target| { 291 self.notice = @errorName(err);
280 _ = self.rt.add(target.target, target.session, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics) catch |err| { 292 self.dirty = true;
281 self.notice = @errorName(err); 293 return;
282 self.dirty = true; 294 };
283 return;
284 };
285 target.deinit();
286 self.staged = null;
287 try self.relayout();
288 } else self.notice = "No staged target";
289 }
290 }, 295 },
291 else => self.notice = "v/b split, h/j/k/l focus, Enter insert, Esc cancel", 296 else => self.notice = "v/b split, h/j/k/l focus, Enter picks session, Esc cancel",
292 } 297 }
293 self.dirty = true; 298 self.dirty = true;
294 } 299 }
295 300
301 /// Commit events sample the actual drawable even if its resize notice
302 /// is still behind this event in SDL's bounded queue.
303 fn dispatch(self: *Events, ev: c.SDL_Event) !bool {
304 if ((ev.type == c.SDL_EVENT_KEY_DOWN and (ev.key.key == c.SDLK_RETURN or ev.key.key == c.SDLK_KP_ENTER)) or ev.type == c.SDL_EVENT_MOUSE_BUTTON_DOWN) {
305 self.geometry_dirty = true;
306 try self.refreshGeometry();
307 }
308 return self.handle(ev);
309 }
296 fn handle(self: *Events, ev: c.SDL_Event) !bool { 310 fn handle(self: *Events, ev: c.SDL_Event) !bool {
297 switch (ev.type) { 311 switch (ev.type) {
298 c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return false, 312 c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return false,
299 c.SDL_EVENT_TEXT_INPUT => { 313 c.SDL_EVENT_TEXT_INPUT => {
300 defer if (self.hook) |h| h.releaseText(ev.text.text); 314 defer if (self.hook) |h| h.releaseText(ev.text.text);
301 if (!self.suppress_text and !self.command_mode) try self.rt.input(std.mem.span(ev.text.text)); 315 if (!self.suppress_text) {
316 if (self.picker) |picker| {
317 try picker.text(std.mem.span(ev.text.text));
318 self.dirty = true;
319 } else if (!self.command_mode) try self.rt.input(std.mem.span(ev.text.text));
320 }
302 self.suppress_text = false; 321 self.suppress_text = false;
303 }, 322 },
304 c.SDL_EVENT_KEY_DOWN => { 323 c.SDL_EVENT_KEY_DOWN => {
@@ -308,6 +327,26 @@ const Events = struct {
308 self.suppress_text = true; 327 self.suppress_text = true;
309 return true; 328 return true;
310 } 329 }
330 if (self.picker) |picker| {
331 const mapped: ?picker_mod.Key = switch (key) {
332 c.SDLK_UP => .up,
333 c.SDLK_DOWN => .down,
334 c.SDLK_K => if (picker.level == .hosts or picker.level == .sessions) .up else null,
335 c.SDLK_J => if (picker.level == .hosts or picker.level == .sessions) .down else null,
336 c.SDLK_RETURN, c.SDLK_KP_ENTER => .enter,
337 c.SDLK_ESCAPE => .escape,
338 c.SDLK_BACKSPACE => .backspace,
339 else => null,
340 };
341 if (mapped) |k| {
342 self.suppress_text = true;
343 if (k == .enter or k == .escape) self.consumed_key = key;
344 try picker.key(k);
345 } else if (ev.key.mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_GUI) != 0) self.suppress_text = true;
346 self.dirty = true;
347 try self.finishPicker();
348 return true;
349 }
311 const prefix = key == c.SDLK_BACKSLASH and ev.key.mod & c.SDL_KMOD_CTRL != 0 and ev.key.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0; 350 const prefix = key == c.SDLK_BACKSLASH and ev.key.mod & c.SDL_KMOD_CTRL != 0 and ev.key.mod & (c.SDL_KMOD_ALT | c.SDL_KMOD_GUI | c.SDL_KMOD_MODE) == 0;
312 if (prefix) { 351 if (prefix) {
313 self.suppress_text = true; 352 self.suppress_text = true;
@@ -349,18 +388,30 @@ const Events = struct {
349 var h: c_int = 0; 388 var h: c_int = 0;
350 if (c.SDL_GetWindowSize(self.win, &w, &h)) { 389 if (c.SDL_GetWindowSize(self.win, &w, &h)) {
351 const at = physicalPoint(ev.button.x, ev.button.y, w, h, self.fb_w, self.fb_h); 390 const at = physicalPoint(ev.button.x, ev.button.y, w, h, self.fb_w, self.fb_h);
352 if (self.layout.hit(at.x, at.y)) |id| _ = self.rt.workspace.focus(id); 391 if (self.picker) |picker| {
392 try picker.click(at.x, at.y);
393 try self.finishPicker();
394 } else if (self.layout.hit(at.x, at.y)) |id| _ = self.rt.workspace.focus(id);
353 self.dirty = true; 395 self.dirty = true;
354 } 396 }
355 }, 397 },
356 c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => self.geometry_dirty = true, 398 c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED, c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED => self.geometry_dirty = true,
357 c.SDL_EVENT_WINDOW_EXPOSED => self.dirty = true, 399 c.SDL_EVENT_WINDOW_EXPOSED => self.dirty = true,
358 else => if (ev.type == self.wake.event_type and self.rt.accepts(.{ .pane = @intFromPtr(ev.user.data1), .generation = @intFromPtr(ev.user.data2) })) { 400 else => if (ev.type == self.wake.event_type and (ev.user.code == 1 or self.rt.accepts(.{ .pane = @intFromPtr(ev.user.data1), .generation = @intFromPtr(ev.user.data2) }))) {
359 self.dirty = true; 401 self.dirty = true;
360 }, 402 },
361 } 403 }
362 return true; 404 return true;
363 } 405 }
406 fn finishPicker(self: *Events) !void {
407 if (self.picker) |picker| if (picker.closed) {
408 const inserted = picker.inserted;
409 picker.deinit();
410 self.picker = null;
411 if (inserted) try self.relayout();
412 self.dirty = true;
413 };
414 }
364 fn sendKey(self: *Events, key: keymap.Event) !void { 415 fn sendKey(self: *Events, key: keymap.Event) !void {
365 var buf: [keymap.max_seq_len]u8 = undefined; 416 var buf: [keymap.max_seq_len]u8 = undefined;
366 const bytes = keymap.encode(key, &buf); 417 const bytes = keymap.encode(key, &buf);
@@ -383,6 +434,11 @@ const Events = struct {
383 self.fb_w = w; 434 self.fb_w = w;
384 self.fb_h = h; 435 self.fb_h = h;
385 self.metrics = measuredMetrics(self.cache.face, scale); 436 self.metrics = measuredMetrics(self.cache.face, scale);
437 if (self.picker) |picker| {
438 picker.width = @intCast(@max(w, 0));
439 picker.height = @intCast(@max(h, 0));
440 picker.metrics = self.metrics;
441 }
386 try self.relayout(); 442 try self.relayout();
387 self.geometry_dirty = false; 443 self.geometry_dirty = false;
388 } 444 }
@@ -454,11 +510,13 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
454 _ = try rt.add(opts.target, opts.session, @intCast(@max(fb_w, 0)), @intCast(@max(fb_h, 0)), metrics); 510 _ = try rt.add(opts.target, opts.session, @intCast(@max(fb_w, 0)), @intCast(@max(fb_h, 0)), metrics);
455 var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null; 511 var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null;
456 defer if (hook) |*h| h.deinit(); 512 defer if (hook) |*h| h.deinit();
457 var events: Events = .{ .rt = &rt, .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cache = &cache, .base_font_px = opts.font_px, .metrics = metrics, .fb_w = fb_w, .fb_h = fb_h }; 513 var events: Events = .{ .rt = &rt, .win = win, .wake = &wake, .hook = if (hook) |*h| h else null, .cache = &cache, .base_font_px = opts.font_px, .metrics = metrics, .fb_w = fb_w, .fb_h = fb_h, .key_path = opts.key_path };
458 if (opts.next) |next| events.staged = try model.Identity.init(alloc, next.target, next.session); 514 defer if (events.picker) |picker| picker.deinit();
459 defer if (events.staged) |*target| target.deinit();
460 try events.relayout(); 515 try events.relayout();
461 var headers: [model.max_panes]Header = @splat(.{}); 516 var headers: [model.max_panes]Header = @splat(.{});
517 var popup: PopupFrame = .{};
518 var popup_lists: quads.Lists = .{};
519 defer popup_lists.deinit(alloc);
462 var visible_blink = false; 520 var visible_blink = false;
463 var blink_phase = true; 521 var blink_phase = true;
464 var blink_until: i64 = 0; 522 var blink_until: i64 = 0;
@@ -471,16 +529,22 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
471 var ev: c.SDL_Event = undefined; 529 var ev: c.SDL_Event = undefined;
472 const wait_ms: c_int = if (events.dirty) 0 else if (hook != null) 16 else 100; 530 const wait_ms: c_int = if (events.dirty) 0 else if (hook != null) 16 else 100;
473 if (c.SDL_WaitEventTimeout(&ev, wait_ms)) { 531 if (c.SDL_WaitEventTimeout(&ev, wait_ms)) {
474 if (!try events.handle(ev)) return 0; 532 if (!try events.dispatch(ev)) return 0;
475 var consumed: usize = 1; 533 var consumed: usize = 1;
476 while (consumed < 128 and c.SDL_PollEvent(&ev)) : (consumed += 1) { 534 while (consumed < 128 and c.SDL_PollEvent(&ev)) : (consumed += 1) {
477 if (!try events.handle(ev)) return 0; 535 if (!try events.dispatch(ev)) return 0;
478 } 536 }
479 } 537 }
480 try events.refreshGeometry(); 538 try events.refreshGeometry();
481 if (usr1_seen.swap(false, .acq_rel)) report(&ring); 539 if (usr1_seen.swap(false, .acq_rel)) report(&ring);
482 const now = std.time.milliTimestamp(); 540 const now = std.time.milliTimestamp();
483 events.dirty = rt.poll(now) or events.dirty; 541 events.dirty = rt.poll(now) or events.dirty;
542 if (events.picker) |picker| if (picker.job) |job| if (job.done.load(.acquire)) {
543 events.geometry_dirty = true;
544 try events.refreshGeometry();
545 events.dirty = (try picker.poll()) or events.dirty;
546 };
547 try events.finishPicker();
484 if (hook) |*h| if (h.state) |path| { 548 if (hook) |*h| if (h.state) |path| {
485 defer alloc.free(path); 549 defer alloc.free(path);
486 h.state = null; 550 h.state = null;
@@ -514,6 +578,14 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
514 var header_row = headers[i].row(); 578 var header_row = headers[i].row();
515 try prepareRow(&cache, &header_row); 579 try prepareRow(&cache, &header_row);
516 } 580 }
581 popup.len = 0;
582 if (events.picker) |picker| {
583 popup.set(picker);
584 for (popup.lines[0..popup.len]) |*line| {
585 var row = line.row();
586 try prepareRow(&cache, &row);
587 }
588 }
517 const had_blink = visible_blink; 589 const had_blink = visible_blink;
518 visible_blink = false; 590 visible_blink = false;
519 const base_ctx: quads.Ctx = .{ .cell_w = face.cell_w, .cell_h = face.cell_h, .ascent = face.ascent, .atlas_w = @floatFromInt(glyph_atlas.width), .atlas_h = @floatFromInt(glyph_atlas.height), .glyphs = .{ .ctx = &cache, .resolve = font.GlyphCache.resolve }, .blink_visible = blink_phase }; 591 const base_ctx: quads.Ctx = .{ .cell_w = face.cell_w, .cell_h = face.cell_h, .ascent = face.ascent, .atlas_w = @floatFromInt(glyph_atlas.width), .atlas_h = @floatFromInt(glyph_atlas.height), .glyphs = .{ .ctx = &cache, .resolve = font.GlyphCache.resolve }, .blink_visible = blink_phase };
@@ -544,6 +616,12 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
544 if (visible_blink and !had_blink) blink_until = now + 500; 616 if (visible_blink and !had_blink) blink_until = now + 500;
545 if (!visible_blink) blink_phase = true; 617 if (!visible_blink) blink_phase = true;
546 try lists.flatten(&instances, alloc); 618 try lists.flatten(&instances, alloc);
619 // Modal background and glyphs are one final layer above all pane
620 // foregrounds. Its glyphs were prepared before the shared UV pass.
621 popup_lists.backgrounds.clearRetainingCapacity();
622 popup_lists.foregrounds.clearRetainingCapacity();
623 try popup.emit(&popup_lists, alloc, base_ctx);
624 try popup_lists.flatten(&instances, alloc);
547 timing.rebuild_us = bench.usSince(&timer); 625 timing.rebuild_us = bench.usSince(&timer);
548 if (glyph_atlas.dirty) renderer.uploadAtlas(&glyph_atlas); 626 if (glyph_atlas.dirty) renderer.uploadAtlas(&glyph_atlas);
549 timing.atlas_us = bench.usSince(&timer); 627 timing.atlas_us = bench.usSince(&timer);
@@ -599,7 +677,7 @@ const Header = struct {
599 fn set(self: *Header, events: *Events, p: model.Placement, live: *const runtime.Live) void { 677 fn set(self: *Header, events: *Events, p: model.Placement, live: *const runtime.Live) void {
600 const focused = events.rt.workspace.tab().focus == p.id; 678 const focused = events.rt.workspace.tab().focus == p.id;
601 const pending = events.rt.workspace.tab().pending; 679 const pending = events.rt.workspace.tab().pending;
602 const hint = if (focused and events.command_mode) " [command: v/b split, h/j/k/l focus, Enter insert, Esc cancel]" else if (pending != null and pending.?.pane == p.id) (if (pending.?.direction == .beside) " [split beside: prefix Enter inserts, Esc cancels]" else " [split below: prefix Enter inserts, Esc cancels]") else events.notice; 680 const hint = if (focused and events.command_mode) " [command: v/b split, h/j/k/l focus, Enter picks session, Esc cancel]" else if (pending != null and pending.?.pane == p.id) (if (pending.?.direction == .beside) " [split beside: prefix Enter chooses session, Esc cancels]" else " [split below: prefix Enter chooses session, Esc cancels]") else events.notice;
603 const label = events.rt.workspace.pane(p.id).?.identity.label; 681 const label = events.rt.workspace.pane(p.id).?.identity.label;
604 var status_buf: [48]u8 = undefined; 682 var status_buf: [48]u8 = undefined;
605 const status: []const u8 = switch (live.status.phase) { 683 const status: []const u8 = switch (live.status.phase) {
@@ -613,14 +691,24 @@ const Header = struct {
613 .exited => std.fmt.bufPrint(&status_buf, "[exited ({d})] ", .{live.status.exit_code}) catch unreachable, 691 .exited => std.fmt.bufPrint(&status_buf, "[exited ({d})] ", .{live.status.exit_code}) catch unreachable,
614 }; 692 };
615 const text = std.fmt.bufPrint(&self.bytes, "{s}{s}{s} {s}", .{ if (focused) "> " else " ", status, hint, label }) catch self.bytes[0..]; 693 const text = std.fmt.bufPrint(&self.bytes, "{s}{s}{s} {s}", .{ if (focused) "> " else " ", status, hint, label }) catch self.bytes[0..];
616 self.len = @min(text.len, @min(self.cells.len, p.header.w / events.metrics.cell_w)); 694 self.setText(text, p.header.w / events.metrics.cell_w);
617 for (self.bytes[0..self.len], 0..) |*ch, i| { 695 }
618 if (ch.* < 0x20 or ch.* >= 0x7f) ch.* = '?'; 696 fn setText(self: *Header, text: []const u8, cols: usize) void {
619 self.cells[i] = .{ .text_off = @intCast(i), .text_len = 1 }; 697 const n = @min(text.len, self.bytes.len);
698 std.mem.copyForwards(u8, &self.bytes, text[0..n]);
699 self.len = 0;
700 var offset: usize = 0;
701 while (offset < n and self.len < @min(cols, self.cells.len)) {
702 const length = std.unicode.utf8ByteSequenceLength(self.bytes[offset]) catch 1;
703 if (offset + length > n) break;
704 if (self.bytes[offset] < 0x20 or self.bytes[offset] == 0x7f) self.bytes[offset] = '?';
705 self.cells[self.len] = .{ .text_off = @intCast(offset), .text_len = length };
706 self.len += 1;
707 offset += length;
620 } 708 }
621 } 709 }
622 fn row(self: *Header) term.grid.Row { 710 fn row(self: *Header) term.grid.Row {
623 return .{ .cells = self.cells[0..self.len], .text = .{ .items = self.bytes[0..self.len], .capacity = 0 } }; 711 return .{ .cells = self.cells[0..self.len], .text = .{ .items = &self.bytes, .capacity = 0 } };
624 } 712 }
625 }; 713 };
626 fn prepareRow(cache: *font.GlyphCache, row: *const term.grid.Row) !void { 714 fn prepareRow(cache: *font.GlyphCache, row: *const term.grid.Row) !void {
@@ -665,7 +753,16 @@ fn writeState(alloc: std.mem.Allocator, path: []const u8, events: *Events) !void
665 var w: c_int = 0; 753 var w: c_int = 0;
666 var h: c_int = 0; 754 var h: c_int = 0;
667 _ = c.SDL_GetWindowSize(events.win, &w, &h); 755 _ = c.SDL_GetWindowSize(events.win, &w, &h);
668 const bytes = try std.json.Stringify.valueAlloc(a, .{ .width = events.fb_w, .height = events.fb_h, .logical_width = w, .logical_height = h, .cell_w = events.metrics.cell_w, .cell_h = events.metrics.cell_h, .divider = events.metrics.divider, .header_h = events.metrics.cell_h, .tab = events.rt.workspace.active_tab_id, .focus = events.rt.workspace.tab().focus, .pending = events.rt.workspace.tab().pending, .command_mode = events.command_mode, .panes = panes[0..events.layout.len] }, .{}); 756 const PopupRow = struct { label: []const u8, rect: model.Rect };
757 const PopupState = struct { level: picker_mod.Level, rows: []const PopupRow, selected: usize, notice: []const u8, host: []const u8, input: []const u8, rect: model.Rect, row_height: u16, first: usize };
758 var picker_state: ?PopupState = null;
759 if (events.picker) |picker| {
760 const view = picker.view();
761 const rows = try a.alloc(PopupRow, picker.rowCount());
762 for (rows, 0..) |*row, i| row.* = .{ .label = picker.rowLabel(i), .rect = view.rowRect(i) };
763 picker_state = .{ .level = picker.level, .rows = rows, .selected = picker.selected, .notice = picker.noticeText(), .host = picker.host(), .input = picker.input.items, .rect = view.rect, .row_height = view.row_height, .first = view.first };
764 }
765 const bytes = try std.json.Stringify.valueAlloc(a, .{ .width = events.fb_w, .height = events.fb_h, .logical_width = w, .logical_height = h, .cell_w = events.metrics.cell_w, .cell_h = events.metrics.cell_h, .divider = events.metrics.divider, .header_h = events.metrics.cell_h, .tab = events.rt.workspace.active_tab_id, .focus = events.rt.workspace.tab().focus, .pending = events.rt.workspace.tab().pending, .command_mode = events.command_mode, .picker = picker_state, .panes = panes[0..events.layout.len] }, .{});
669 try writeArtifact(a, path, &.{bytes}); 766 try writeArtifact(a, path, &.{bytes});
670 } 767 }
671 fn writeArtifact(alloc: std.mem.Allocator, path: []const u8, parts: []const []const u8) !void { 768 fn writeArtifact(alloc: std.mem.Allocator, path: []const u8, parts: []const []const u8) !void {
@@ -830,3 +927,104 @@ test "later pane atlas growth precedes earlier pane UV generation" {
830 try std.testing.expectApproxEqAbs(expected, glyph.v0, 0.00001); 927 try std.testing.expectApproxEqAbs(expected, glyph.v0, 0.00001);
831 try std.testing.expect(glyph.v1 <= @as(f32, @floatFromInt(entry.y + entry.h)) / @as(f32, @floatFromInt(glyph_atlas.height))); 928 try std.testing.expect(glyph.v1 <= @as(f32, @floatFromInt(entry.y + entry.h)) / @as(f32, @floatFromInt(glyph_atlas.height)));
832 } 929 }
930
931 const PopupFrame = struct {
932 lines: [44]Header = @splat(.{}),
933 len: usize = 0,
934 rect: model.Rect = .{},
935 selected_line: ?usize = null,
936 fn set(self: *PopupFrame, picker: *const picker_mod.Picker) void {
937 const view = picker.view();
938 self.rect = view.rect;
939 self.len = view.shown + 4;
940 self.selected_line = null;
941 const cols = view.rect.w / picker.metrics.cell_w;
942 for (self.lines[0..self.len]) |*line| line.setText("", cols);
943 self.lines[0].setText(picker.title(), cols);
944 for (0..view.shown) |i| {
945 const index = view.first + i;
946 self.lines[i + 1].setText(picker.rowLabel(index), cols);
947 if (index == picker.selected) self.selected_line = i + 1;
948 }
949 const editing = picker.level == .host_name or picker.level == .session_name;
950 if (editing) {
951 // Keep the caret end visible while preserving the complete editor
952 // buffer; the actual selection/name does not truncate with a row.
953 var start = picker.input.items.len -| cols;
954 while (start < picker.input.items.len and picker.input.items[start] & 0xc0 == 0x80) start += 1;
955 self.lines[1].setText(picker.input.items[start..], cols);
956 self.selected_line = 1;
957 } else if (picker.level != .hosts) self.lines[self.len - 3].setText(picker.host(), cols);
958 self.lines[self.len - 2].setText(picker.noticeText(), cols);
959 self.lines[self.len - 1].setText(if (editing) "Enter confirms | Esc goes back" else "Up/Down or j/k choose | Enter selects | Esc goes back", cols);
960 }
961 fn emit(self: *PopupFrame, lists: *quads.Lists, alloc: std.mem.Allocator, base: quads.Ctx) !void {
962 if (self.len == 0) return;
963 try lists.backgrounds.append(alloc, quads.solid(@floatFromInt(self.rect.x), @floatFromInt(self.rect.y), @floatFromInt(self.rect.w), @floatFromInt(self.rect.h), 0x1c2632ff));
964 for (self.lines[0..self.len], 0..) |*line, i| {
965 var ctx = base;
966 ctx.x0 = @floatFromInt(self.rect.x);
967 ctx.y0 = @floatFromInt(self.rect.y + @as(u32, @intCast(i)) * base.cell_h);
968 ctx.default_bg = if (self.selected_line == i) 0x435e75ff else 0x1c2632ff;
969 if (self.selected_line == i) try lists.backgrounds.append(alloc, quads.solid(ctx.x0, ctx.y0, @floatFromInt(self.rect.w), @floatFromInt(base.cell_h), 0x435e75ff));
970 var row = line.row();
971 _ = try quads.rowInstances(lists, alloc, &row, @intCast(row.cells.len), 0, 0, ctx);
972 }
973 clipPane(lists, 0, 0, self.rect);
974 }
975 };
976
977 test "modal Enter remains consumed through insertion and repeated keydown until release" {
978 const a = std.testing.allocator;
979 var rt = runtime.Runtime.init(a, .{});
980 defer rt.deinit();
981 const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 };
982 const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, metrics);
983 rt.workspace.arm(.beside);
984 var next: u64 = 2;
985 const picker = try a.create(picker_mod.Picker);
986 picker.* = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = rt.get(id).?.key, .origin_tab = rt.workspace.active_tab_id, .pending = rt.workspace.tab().pending.?, .ticket = .{ .generation = 1, .owner = id, .attachment_generation = 1 }, .next_generation = &next, .key_path = null, .width = 800, .height = 600, .metrics = metrics, .wake = null, .wake_ctx = null };
987 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
988 var events: Events = .{ .rt = &rt, .win = undefined, .wake = &wake, .hook = null, .cache = undefined, .base_font_px = 16, .metrics = metrics, .fb_w = 800, .fb_h = 600, .picker = picker };
989 defer if (events.picker) |p| p.deinit();
990 try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } });
991 picker.level = .session_name;
992 var event = std.mem.zeroes(c.SDL_Event);
993 event.key.type = c.SDL_EVENT_KEY_DOWN;
994 event.key.key = c.SDLK_J;
995 try std.testing.expect(try events.handle(event));
996 event.text.type = c.SDL_EVENT_TEXT_INPUT;
997 event.text.text = "j";
998 try std.testing.expect(try events.handle(event));
999 try std.testing.expectEqualStrings("j", picker.input.items);
1000 // Modifier chords belong to the modal, even double-prefix. Neither the
1001 // editor nor the terminal receives its associated text.
1002 event = std.mem.zeroes(c.SDL_Event);
1003 event.key.type = c.SDL_EVENT_KEY_DOWN;
1004 event.key.key = c.SDLK_BACKSLASH;
1005 event.key.mod = c.SDL_KMOD_CTRL;
1006 try std.testing.expect(try events.handle(event));
1007 try std.testing.expect(try events.handle(event));
1008 try std.testing.expect(!events.command_mode and events.suppress_text);
1009 picker.level = .sessions;
1010 picker.session_count = 1;
1011 picker.sessions[0][0] = 'x';
1012 picker.session_lens[0] = 1;
1013 event = std.mem.zeroes(c.SDL_Event);
1014 event.key.type = c.SDL_EVENT_KEY_DOWN;
1015 event.key.key = c.SDLK_RETURN;
1016 try std.testing.expect(try events.handle(event));
1017 try std.testing.expect(events.picker == null);
1018 try std.testing.expectEqual(@as(usize, 2), events.layout.len);
1019 event.key.repeat = true;
1020 try std.testing.expect(try events.handle(event));
1021 try std.testing.expect(events.suppress_text and events.consumed_key == c.SDLK_RETURN);
1022 event.key.type = c.SDL_EVENT_KEY_UP;
1023 try std.testing.expect(try events.handle(event));
1024 try std.testing.expect(events.consumed_key == null);
1025 event = std.mem.zeroes(c.SDL_Event);
1026 event.key.type = c.SDL_EVENT_KEY_DOWN;
1027 event.key.key = c.SDLK_X;
1028 try std.testing.expect(try events.handle(event));
1029 try std.testing.expect(!events.suppress_text);
1030 }
src/gui/native.zig
Old New
@@ -13,6 +13,7 @@ const term = @import("term");
13 pub const frame = @import("frame.zig"); 13 pub const frame = @import("frame.zig");
14 pub const workspace = @import("workspace.zig"); 14 pub const workspace = @import("workspace.zig");
15 pub const runtime = @import("runtime.zig"); 15 pub const runtime = @import("runtime.zig");
16 pub const picker = @import("picker.zig");
16 pub const bench = @import("bench.zig"); 17 pub const bench = @import("bench.zig");
17 pub const atlas = @import("atlas.zig"); 18 pub const atlas = @import("atlas.zig");
18 pub const font = @import("font.zig"); 19 pub const font = @import("font.zig");
@@ -29,6 +30,7 @@ test {
29 _ = frame; 30 _ = frame;
30 _ = workspace; 31 _ = workspace;
31 _ = runtime; 32 _ = runtime;
33 _ = picker;
32 _ = bench; 34 _ = bench;
33 _ = atlas; 35 _ = atlas;
34 _ = font; 36 _ = font;
src/gui/picker.zig
Old New
@@ -0,0 +1,392 @@
1 //! Modal host/session selection policy. No window calls or transport IO run
2 //! here: discovery jobs own IO, while this object owns their UI generations.
3 const std = @import("std");
4 const client = @import("client");
5 const proto = @import("term").protocol;
6 const model = @import("workspace.zig");
7 const runtime = @import("runtime.zig");
8 const discovery = client.discovery;
9 pub const Level = enum { hosts, sessions, host_name, session_name, busy };
10 pub const Key = enum { up, down, enter, escape, backspace };
11 const Host = struct { label: []const u8, target: ?client.Target, reason: []const u8 = "" };
12 pub const View = struct {
13 rect: model.Rect,
14 first: usize,
15 shown: usize,
16 row_height: u16,
17 pub fn rowRect(self: View, index: usize) model.Rect {
18 if (index < self.first or index >= self.first + self.shown) return .{};
19 return model.Rect.intersect(.{ .x = self.rect.x, .y = self.rect.y + self.row_height * @as(u32, @intCast(1 + index - self.first)), .w = self.rect.w, .h = self.row_height }, self.rect);
20 }
21 };
22 pub const Picker = struct {
23 alloc: std.mem.Allocator,
24 arena: std.heap.ArenaAllocator,
25 rt: *runtime.Runtime,
26 origin: model.Attachment,
27 origin_tab: model.TabId,
28 pending: model.Pending,
29 ticket: discovery.Ticket,
30 next_generation: *u64,
31 level: Level = .hosts,
32 selected: usize = 0,
33 host_index: usize = 0,
34 hosts: std.ArrayList(Host) = .empty,
35 sessions: [proto.sessions_max][proto.session_name_max]u8 = undefined,
36 session_lens: [proto.sessions_max]u8 = @splat(0),
37 session_count: usize = 0,
38 input: std.ArrayList(u8) = .empty,
39 notice: [1024]u8 = @splat(0),
40 notice_len: usize = 0,
41 job: ?*discovery.Job = null,
42 wake_ctx: ?*anyopaque,
43 wake: ?*const fn (?*anyopaque, discovery.Ticket) void,
44 catalogue: ?[]const u8 = null,
45 key_path: ?[]const u8,
46 width: u32,
47 height: u32,
48 metrics: model.Metrics,
49 closed: bool = false,
50 inserted: bool = false,
51
52 pub fn init(alloc: std.mem.Allocator, rt: *runtime.Runtime, next_generation: *u64, key_path: ?[]const u8, width: u32, height: u32, metrics: model.Metrics, wake_ctx: ?*anyopaque, wake: ?*const fn (?*anyopaque, discovery.Ticket) void) !*Picker {
53 if (rt.workspace.tab().pending == null) rt.workspace.arm(.beside);
54 const pending = rt.workspace.tab().pending orelse return error.MissingPane;
55 const pane = rt.workspace.pane(pending.pane) orelse return error.MissingPane;
56 const self = try alloc.create(Picker);
57 self.* = .{ .alloc = alloc, .arena = std.heap.ArenaAllocator.init(alloc), .rt = rt, .origin = .{ .pane = pane.id, .generation = pane.generation }, .origin_tab = rt.workspace.active_tab_id, .pending = pending, .ticket = .{ .generation = next_generation.*, .owner = pane.id, .attachment_generation = pane.generation }, .next_generation = next_generation, .key_path = key_path, .width = width, .height = height, .metrics = metrics, .wake_ctx = wake_ctx, .wake = wake };
58 next_generation.* += 1;
59 errdefer self.deinit();
60 const a = self.arena.allocator();
61 self.catalogue = client.hosts.statePath(a) catch null;
62 if (self.catalogue) |path| {
63 const loaded = client.hosts.load(a, path) catch |err| blk: {
64 self.setNotice(@errorName(err));
65 break :blk client.hosts.Hosts{};
66 };
67 for (loaded.lines.items) |line| {
68 const target = client.Target.fromSpec(a, try client.hosts.parse(line), key_path, client.quic_idle_ms_default, false) catch |err| {
69 try self.hosts.append(a, .{ .label = line, .target = null, .reason = @errorName(err) });
70 continue;
71 };
72 try self.hosts.append(a, .{ .label = line, .target = target });
73 }
74 }
75 const current = pane.identity.target;
76 const label = switch (current) {
77 .sock => |path| try std.fmt.allocPrint(a, "--sock {s}", .{path}),
78 .quic => |q| try std.fmt.allocPrint(a, "quic://{s}", .{q.host_port}),
79 .hand => |h| h.host,
80 .via => "Current connection",
81 };
82 var found = false;
83 for (self.hosts.items) |*entry| if (std.mem.eql(u8, entry.label, label)) {
84 entry.target = try discovery.cloneTarget(a, current);
85 entry.reason = "";
86 found = true;
87 break;
88 };
89 if (!found) try self.hosts.append(a, .{ .label = try a.dupe(u8, label), .target = try discovery.cloneTarget(a, current) });
90 return self;
91 }
92 pub fn deinit(self: *Picker) void {
93 if (self.job) |job| job.stop();
94 self.input.deinit(self.alloc);
95 self.arena.deinit();
96 self.alloc.destroy(self);
97 }
98 pub fn noticeText(self: *const Picker) []const u8 {
99 return self.notice[0..self.notice_len];
100 }
101 pub fn host(self: *const Picker) []const u8 {
102 return if (self.hosts.items.len > 0) self.hosts.items[self.host_index].label else "";
103 }
104 pub fn title(self: *const Picker) []const u8 {
105 return switch (self.level) {
106 .hosts => "Choose host",
107 .sessions => "Choose session",
108 .host_name => "Add host",
109 .session_name => "New session",
110 .busy => if (self.job != null and self.job.?.opts.operation == .create) "Creating session..." else "Loading sessions...",
111 };
112 }
113 pub fn rowCount(self: *const Picker) usize {
114 return switch (self.level) {
115 .hosts => self.hosts.items.len + 1,
116 .sessions => self.session_count + 1,
117 else => 0,
118 };
119 }
120 pub fn rowLabel(self: *const Picker, index: usize) []const u8 {
121 return switch (self.level) {
122 .hosts => if (index == self.hosts.items.len) "Add host..." else self.hosts.items[index].label,
123 .sessions => if (index == self.session_count) "New session..." else self.sessions[index][0..self.session_lens[index]],
124 else => "",
125 };
126 }
127 pub fn view(self: *const Picker) View {
128 const cw: u32 = self.metrics.cell_w;
129 const ch: u32 = self.metrics.cell_h;
130 const w = @min(self.width, cw * 84);
131 const available = self.height / ch;
132 const shown = @min(self.rowCount(), @min(available -| 4, 40));
133 const h = @min(self.height, (@as(u32, @intCast(shown)) + 4) * ch);
134 const first = if (shown > 0 and self.selected >= shown) self.selected - shown + 1 else 0;
135 return .{ .rect = .{ .x = (self.width - w) / 2, .y = (self.height - h) / 2, .w = w, .h = h }, .first = first, .shown = shown, .row_height = self.metrics.cell_h };
136 }
137 pub fn text(self: *Picker, bytes: []const u8) !void {
138 if (self.level != .host_name and self.level != .session_name) return;
139 const limit: usize = if (self.level == .session_name) proto.session_name_max else 1024;
140 if (self.input.items.len + bytes.len > limit) {
141 self.setNotice("Name is too long");
142 return;
143 }
144 if (!std.unicode.utf8ValidateSlice(bytes)) return;
145 for (bytes) |b| if (b < 0x20 or b == 0x7f) return;
146 try self.input.appendSlice(self.alloc, bytes);
147 self.notice_len = 0;
148 }
149 pub fn key(self: *Picker, pressed: Key) !void {
150 switch (pressed) {
151 .up => if (self.rowCount() > 0) {
152 self.selected -|= 1;
153 },
154 .down => if (self.rowCount() > 0) {
155 self.selected = @min(self.selected + 1, self.rowCount() - 1);
156 },
157 .backspace => if ((self.level == .host_name or self.level == .session_name) and self.input.items.len > 0) {
158 var n = self.input.items.len - 1;
159 while (n > 0 and self.input.items[n] & 0xc0 == 0x80) n -= 1;
160 self.input.items.len = n;
161 self.notice_len = 0;
162 },
163 .escape => try self.back(),
164 .enter => try self.choose(),
165 }
166 }
167 pub fn click(self: *Picker, x: u32, y: u32) !void {
168 const v = self.view();
169 for (v.first..v.first + v.shown) |i| if (v.rowRect(i).contains(x, y)) {
170 self.selected = i;
171 try self.choose();
172 return;
173 };
174 }
175 fn back(self: *Picker) !void {
176 self.notice_len = 0;
177 switch (self.level) {
178 .hosts => self.closed = true,
179 .sessions, .host_name => {
180 self.level = .hosts;
181 self.selected = self.host_index;
182 },
183 .session_name => try self.list(),
184 .busy => {
185 const job = self.job.?;
186 job.cancel();
187 const result = job.join();
188 job.stop();
189 self.job = null;
190 self.ticket.generation = self.next_generation.*;
191 self.next_generation.* += 1;
192 if (result.may_have_created) {
193 self.level = .session_name;
194 self.outcomeNotice(result.phase == .created, "Esc refreshes sessions; no automatic retry.");
195 } else {
196 self.level = .hosts;
197 self.selected = self.host_index;
198 }
199 },
200 }
201 }
202 fn choose(self: *Picker) !void {
203 self.notice_len = 0;
204 switch (self.level) {
205 .hosts => {
206 if (self.selected == self.hosts.items.len) {
207 self.level = .host_name;
208 self.input.clearRetainingCapacity();
209 } else {
210 self.host_index = self.selected;
211 try self.list();
212 }
213 },
214 .sessions => {
215 if (self.selected == self.session_count) {
216 self.level = .session_name;
217 self.input.clearRetainingCapacity();
218 } else try self.insert(self.rowLabel(self.selected), false);
219 },
220 .host_name => {
221 const a = self.arena.allocator();
222 const spelling = self.input.items;
223 const spec = client.hosts.parse(spelling) catch |err| {
224 self.setNotice(client.hosts.reason(err));
225 return;
226 };
227 const target = client.Target.fromSpec(a, spec, self.key_path, client.quic_idle_ms_default, false) catch |err| {
228 self.setNotice(@errorName(err));
229 return;
230 };
231 const path = self.catalogue orelse {
232 self.setNotice("Host catalogue path is unavailable");
233 return;
234 };
235 _ = client.hosts.record(self.alloc, path, spelling) catch |err| {
236 self.setNotice(@errorName(err));
237 return;
238 };
239 for (self.hosts.items, 0..) |h, i| if (std.mem.eql(u8, h.label, spelling)) {
240 self.host_index = i;
241 try self.list();
242 return;
243 };
244 try self.hosts.append(a, .{ .label = try a.dupe(u8, spelling), .target = try discovery.cloneTarget(a, target) });
245 self.host_index = self.hosts.items.len - 1;
246 try self.list();
247 },
248 .session_name => {
249 const name = self.input.items;
250 if (!proto.validSessionName(name)) {
251 self.setNotice("Choose a valid session name (no spaces)");
252 return;
253 }
254 const placement = self.preflight() catch |err| {
255 self.setNotice(@errorName(err));
256 return;
257 };
258 try self.start(.{ .create = .{ .name = name, .cols = placement.cols, .rows = placement.rows } });
259 },
260 .busy => {},
261 }
262 }
263 fn validOrigin(self: *Picker) bool {
264 const pending = self.rt.workspace.tab().pending orelse return false;
265 return self.rt.workspace.active_tab_id == self.origin_tab and self.rt.accepts(self.origin) and std.meta.eql(pending, self.pending);
266 }
267 fn preflight(self: *Picker) !model.Placement {
268 if (!self.validOrigin()) return error.OriginChanged;
269 return self.rt.workspace.preview(self.width, self.height, self.metrics);
270 }
271 fn insert(self: *Picker, name: []const u8, created: bool) !void {
272 if (!self.validOrigin()) {
273 self.insertionFailed(error.OriginChanged, created);
274 return;
275 }
276 _ = self.rt.addWithPolicy(self.hosts.items[self.host_index].target orelse {
277 self.setNotice("Target is unavailable");
278 return;
279 }, name, self.width, self.height, self.metrics, true) catch |err| {
280 self.insertionFailed(err, created);
281 return;
282 };
283 self.inserted = true;
284 self.closed = true;
285 }
286 fn insertionFailed(self: *Picker, err: anyerror, created: bool) void {
287 if (created) self.outcomeNotice(true, "Pane insertion failed. Esc refreshes available sessions.") else self.setNotice(@errorName(err));
288 }
289 fn list(self: *Picker) !void {
290 if (self.hosts.items[self.host_index].target == null) {
291 self.level = .hosts;
292 self.setNotice(self.hosts.items[self.host_index].reason);
293 return;
294 }
295 self.selected = 0;
296 try self.start(.list);
297 }
298 fn start(self: *Picker, operation: discovery.Operation) !void {
299 std.debug.assert(self.job == null);
300 self.ticket.generation = self.next_generation.*;
301 self.next_generation.* += 1;
302 self.job = discovery.Job.start(self.alloc, .{ .ticket = self.ticket, .target = self.hosts.items[self.host_index].target.?, .operation = operation, .wake_ctx = self.wake_ctx, .wake = self.wake }) catch |err| {
303 self.setNotice(@errorName(err));
304 return;
305 };
306 self.level = .busy;
307 }
308 pub fn poll(self: *Picker) !bool {
309 const job = self.job orelse return false;
310 if (!job.done.load(.acquire)) return false;
311 const result = job.join();
312 const creating = job.opts.operation == .create;
313 job.stop();
314 self.job = null;
315 try self.applyResult(result, creating);
316 return true;
317 }
318 fn applyResult(self: *Picker, result: discovery.Result, creating: bool) !void {
319 if (!std.meta.eql(result.ticket, self.ticket) or !self.validOrigin()) {
320 self.level = .hosts;
321 if (result.phase == .created) self.outcomeNotice(true, "Destination changed. Refresh sessions to choose it.") else if (result.may_have_created) self.outcomeNotice(false, "Destination changed. Refresh sessions before retrying.") else self.setNotice("The pane for this request changed");
322 return;
323 }
324 switch (result.phase) {
325 .sessions => {
326 self.session_count = 0;
327 var iter = proto.sessionsIter(result.text());
328 while (iter.next()) |name| {
329 if (self.session_count == self.sessions.len) break;
330 @memcpy(self.sessions[self.session_count][0..name.len], name);
331 self.session_lens[self.session_count] = @intCast(name.len);
332 self.session_count += 1;
333 }
334 self.level = .sessions;
335 self.selected = 0;
336 },
337 .created => {
338 self.level = .session_name;
339 try self.insert(self.input.items, true);
340 },
341 else => {
342 self.level = if (creating) .session_name else .sessions;
343 if (!creating) self.session_count = 0;
344 if (result.may_have_created) self.outcomeNotice(false, "Esc refreshes sessions; no automatic retry.") else self.setNotice(result.reasonText());
345 },
346 }
347 return;
348 }
349 fn outcomeNotice(self: *Picker, created: bool, suffix: []const u8) void {
350 const message = std.fmt.bufPrint(&self.notice, "{s} '{s}'. {s}", .{ if (created) "Session created" else "Creation outcome unknown for", self.input.items, suffix }) catch self.notice[0..];
351 self.notice_len = message.len;
352 }
353 fn setNotice(self: *Picker, text_: []const u8) void {
354 self.notice_len = @min(text_.len, self.notice.len);
355 @memcpy(self.notice[0..self.notice_len], text_[0..self.notice_len]);
356 }
357 };
358
359 test "picker validates fit before create and preserves remote outcome after origin or tab changes" {
360 const a = std.testing.allocator;
361 var rt = runtime.Runtime.init(a, .{});
362 defer rt.deinit();
363 const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16 };
364 const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, metrics);
365 rt.workspace.arm(.beside);
366 var next: u64 = 2;
367 var picker: Picker = .{ .alloc = a, .arena = std.heap.ArenaAllocator.init(a), .rt = &rt, .origin = rt.get(id).?.key, .origin_tab = rt.workspace.active_tab_id, .pending = rt.workspace.tab().pending.?, .ticket = .{ .generation = 1, .owner = id, .attachment_generation = 1 }, .next_generation = &next, .key_path = null, .width = 1, .height = 1, .metrics = metrics, .wake = null, .wake_ctx = null };
368 defer picker.arena.deinit();
369 defer picker.input.deinit(a);
370 try picker.hosts.append(picker.arena.allocator(), .{ .label = "fixture", .target = .{ .via = "cat" } });
371 picker.level = .session_name;
372 try picker.text("new-shell");
373 try picker.key(.enter);
374 try std.testing.expect(picker.job == null);
375 try std.testing.expect(std.mem.indexOf(u8, picker.noticeText(), "TooSmall") != null);
376 try std.testing.expectEqual(@as(usize, 1), rt.workspace.layout(800, 600, metrics).len);
377
378 picker.width = 800;
379 picker.height = 600;
380 const next_id = rt.workspace.next_pane_id;
381 _ = try picker.preflight();
382 try std.testing.expectEqual(next_id, rt.workspace.next_pane_id);
383 rt.workspace.active_tab_id += 1;
384 try picker.applyResult(.{ .ticket = picker.ticket, .phase = .created, .may_have_created = true }, true);
385 try std.testing.expect(!picker.inserted and !picker.closed);
386 try std.testing.expect(std.mem.indexOf(u8, picker.noticeText(), "Session created 'new-shell'") != null);
387 rt.workspace.active_tab_id = picker.origin_tab;
388 rt.workspace.cancel();
389 try picker.applyResult(.{ .ticket = picker.ticket, .phase = .cancelled, .may_have_created = true }, true);
390 try std.testing.expect(std.mem.indexOf(u8, picker.noticeText(), "outcome unknown") != null);
391 try std.testing.expectEqual(@as(usize, 1), rt.workspace.layout(800, 600, metrics).len);
392 }
src/gui/runtime.zig
Old New
@@ -73,6 +73,9 @@ pub const Runtime = struct {
73 return live.key.generation == key.generation; 73 return live.key.generation == key.generation;
74 } 74 }
75 pub fn add(self: *Runtime, target: client.Target, session: []const u8, width: u32, height: u32, metrics: model.Metrics) !model.PaneId { 75 pub fn add(self: *Runtime, target: client.Target, session: []const u8, width: u32, height: u32, metrics: model.Metrics) !model.PaneId {
76 return self.addWithPolicy(target, session, width, height, metrics, false);
77 }
78 pub fn addWithPolicy(self: *Runtime, target: client.Target, session: []const u8, width: u32, height: u32, metrics: model.Metrics, existing_only: bool) !model.PaneId {
76 var prepared = try self.workspace.prepare(target, session, width, height, metrics); 79 var prepared = try self.workspace.prepare(target, session, width, height, metrics);
77 errdefer prepared.discard(self.alloc); 80 errdefer prepared.discard(self.alloc);
78 const live = try self.alloc.create(Live); 81 const live = try self.alloc.create(Live);
@@ -80,7 +83,7 @@ pub const Runtime = struct {
80 const grid = try term.grid.Grid.init(self.alloc, 1, 1); 83 const grid = try term.grid.Grid.init(self.alloc, 1, 1);
81 errdefer grid.deinit(); 84 errdefer grid.deinit();
82 live.* = .{ .key = .{ .pane = prepared.pane.id, .generation = prepared.pane.generation }, .pump = undefined, .snapshot = grid, .size = .{ .cols = prepared.placement.cols, .rows = prepared.placement.rows }, .notify = self.notify }; 85 live.* = .{ .key = .{ .pane = prepared.pane.id, .generation = prepared.pane.generation }, .pump = undefined, .snapshot = grid, .size = .{ .cols = prepared.placement.cols, .rows = prepared.placement.rows }, .notify = self.notify };
83 live.pump = try Pump.start(self.alloc, .{ .target = prepared.pane.identity.target, .session = prepared.pane.identity.session, .cols = live.size.cols, .rows = live.size.rows, .wake = Live.wake, .wake_ctx = live }); 86 live.pump = try Pump.start(self.alloc, .{ .target = prepared.pane.identity.target, .session = prepared.pane.identity.session, .cols = live.size.cols, .rows = live.size.rows, .existing_only = existing_only, .wake = Live.wake, .wake_ctx = live });
84 for (&self.lives) |*slot| if (slot.* == null) { 87 for (&self.lives) |*slot| if (slot.* == null) {
85 slot.* = live; 88 slot.* = live;
86 break; 89 break;
src/gui/workspace.zig
Old New
@@ -61,7 +61,7 @@ pub const Identity = struct {
61 var arena = std.heap.ArenaAllocator.init(alloc); 61 var arena = std.heap.ArenaAllocator.init(alloc);
62 errdefer arena.deinit(); 62 errdefer arena.deinit();
63 const a = arena.allocator(); 63 const a = arena.allocator();
64 const owned = try cloneTarget(a, target); 64 const owned = try client.discovery.cloneTarget(a, target);
65 const name = try a.dupe(u8, @import("term").protocol.resolveName(session)); 65 const name = try a.dupe(u8, @import("term").protocol.resolveName(session));
66 const host = switch (owned) { 66 const host = switch (owned) {
67 .sock => |s| s, 67 .sock => |s| s,
@@ -76,33 +76,6 @@ pub const Identity = struct {
76 self.arena.deinit(); 76 self.arena.deinit();
77 } 77 }
78 }; 78 };
79 fn cloneTarget(a: std.mem.Allocator, target: client.Target) !client.Target {
80 return switch (target) {
81 .sock => |s| .{ .sock = try a.dupe(u8, s) },
82 .via => |s| .{ .via = try a.dupe(u8, s) },
83 .quic => |q| blk: {
84 var copy = q;
85 copy.host_port = try a.dupe(u8, q.host_port);
86 copy.key_path = try a.dupe(u8, q.key_path);
87 break :blk .{ .quic = copy };
88 },
89 .hand => |h| blk: {
90 var copy = h;
91 copy.host = try a.dupe(u8, h.host);
92 copy.ssh_argv = try cloneArgv(a, h.ssh_argv);
93 copy.asked_argv = try cloneArgv(a, h.asked_argv);
94 if (h.cache_path) |s| copy.cache_path = try a.dupe(u8, s);
95 if (h.ask_sock) |s| copy.ask_sock = try a.dupe(u8, s);
96 copy.ask_exe = try a.dupe(u8, h.ask_exe);
97 break :blk .{ .hand = copy };
98 },
99 };
100 }
101 fn cloneArgv(a: std.mem.Allocator, args: []const []const u8) ![]const []const u8 {
102 const out = try a.alloc([]const u8, args.len);
103 for (args, out) |s, *d| d.* = try a.dupe(u8, s);
104 return out;
105 }
106 pub const Pane = struct { id: PaneId, generation: u64 = 1, identity: Identity }; 79 pub const Pane = struct { id: PaneId, generation: u64 = 1, identity: Identity };
107 const Node = union(enum) { empty, leaf: PaneId, split: struct { direction: Direction, a: u8, b: u8 } }; 80 const Node = union(enum) { empty, leaf: PaneId, split: struct { direction: Direction, a: u8, b: u8 } };
108 const Tree = struct { 81 const Tree = struct {
@@ -249,7 +222,20 @@ pub const Workspace = struct {
249 self.tab().focus = id; 222 self.tab().focus = id;
250 return true; 223 return true;
251 } 224 }
225 /// Allocation-free sizing for an explicit create, before its remote side
226 /// effect. prepare uses the identical plan when it commits a real pane.
227 pub fn preview(self: *Workspace, width: u32, height: u32, m: Metrics) !Placement {
228 return (try self.insertionPlan(width, height, m)).placement;
229 }
252 pub fn prepare(self: *Workspace, target: client.Target, session: []const u8, width: u32, height: u32, m: Metrics) !Prepared { 230 pub fn prepare(self: *Workspace, target: client.Target, session: []const u8, width: u32, height: u32, m: Metrics) !Prepared {
231 const plan = try self.insertionPlan(width, height, m);
232 const p = try self.alloc.create(Pane);
233 errdefer self.alloc.destroy(p);
234 p.* = .{ .id = self.next_pane_id, .identity = try Identity.init(self.alloc, target, session) };
235 self.next_pane_id += 1;
236 return .{ .pane = p, .tree = plan.tree, .placement = plan.placement };
237 }
238 fn insertionPlan(self: *Workspace, width: u32, height: u32, m: Metrics) !struct { tree: Tree, placement: Placement } {
253 const t = self.tab(); 239 const t = self.tab();
254 var count: usize = 0; 240 var count: usize = 0;
255 for (t.panes) |p| { 241 for (t.panes) |p| {
@@ -269,14 +255,10 @@ pub const Workspace = struct {
269 var tree = t.tree; 255 var tree = t.tree;
270 const id = self.next_pane_id; 256 const id = self.next_pane_id;
271 try tree.insert(origin, id, direction); 257 try tree.insert(origin, id, direction);
272 const p = try self.alloc.create(Pane);
273 errdefer self.alloc.destroy(p);
274 p.* = .{ .id = id, .identity = try Identity.init(self.alloc, target, session) };
275 self.next_pane_id += 1;
276 var flat: Layout = .{}; 258 var flat: Layout = .{};
277 const min = tree.minimum(tree.root.?, m); 259 const min = tree.minimum(tree.root.?, m);
278 tree.flatten(tree.root.?, .{ .w = @max(width, min.w), .h = @max(height, min.h) }, .{ .w = width, .h = height }, m, &flat); 260 tree.flatten(tree.root.?, .{ .w = @max(width, min.w), .h = @max(height, min.h) }, .{ .w = width, .h = height }, m, &flat);
279 return .{ .pane = p, .tree = tree, .placement = flat.get(id).? }; 261 return .{ .tree = tree, .placement = flat.get(id).? };
280 } 262 }
281 /// Call immediately after preparing the attachment; no other model edits 263 /// Call immediately after preparing the attachment; no other model edits
282 /// may occur between prepare and commit. This operation cannot allocate. 264 /// may occur between prepare and commit. This operation cannot allocate.
src/link.zig
Old New
@@ -9,6 +9,13 @@ const std = @import("std");
9 const proto = @import("term").protocol; 9 const proto = @import("term").protocol;
10 const quic = @import("quic"); 10 const quic = @import("quic");
11 11
12 /// Reap an owned helper even if it ignores SIGTERM. Never signal a child
13 /// already waited for: its PID may have been reused. wait also closes streams.
14 pub fn terminateChild(child: *std.process.Child) void {
15 if (child.term == null) std.posix.kill(child.id, std.posix.SIG.KILL) catch {};
16 _ = child.wait() catch {};
17 }
18
12 /// Three outcomes, not two: QUIC's socket goes readable for acks and half 19 /// Three outcomes, not two: QUIC's socket goes readable for acks and half
13 /// frames, so `null` cannot keep the socket path's meaning of "peer gone" 20 /// frames, so `null` cannot keep the socket path's meaning of "peer gone"
14 /// without making every partial frame a reconnect. (Moved from client.zig.) 21 /// without making every partial frame a reconnect. (Moved from client.zig.)
@@ -276,7 +283,8 @@ pub const Link = union(enum) {
276 /// entry, and an abort then closes the same value again through the 283 /// entry, and an abort then closes the same value again through the
277 /// pump's defer. (The pipe-kill ordering is client.Transport.close's, 284 /// pump's defer. (The pipe-kill ordering is client.Transport.close's,
278 /// moved: stdin first so the command sees EOF and can wind down its 285 /// moved: stdin first so the command sees EOF and can wind down its
279 /// remote end, then TERM. kill() waitpid()s internally — no zombie.) 286 /// remote end, then forced termination and wait — no zombie or a
287 /// SIGTERM-ignoring child that can hold cancellation indefinitely.)
280 pub fn close(self: *Link) void { 288 pub fn close(self: *Link) void {
281 switch (self.*) { 289 switch (self.*) {
282 .fd => |fd| { 290 .fd => |fd| {
@@ -288,7 +296,7 @@ pub const Link = union(enum) {
288 in.close(); 296 in.close();
289 p.child.stdin = null; 297 p.child.stdin = null;
290 } 298 }
291 _ = p.child.kill() catch {}; 299 terminateChild(&p.child);
292 }, 300 },
293 .quic => |*q| { 301 .quic => |*q| {
294 q.qout.deinit(q.alloc); 302 q.qout.deinit(q.alloc);
@@ -493,3 +501,21 @@ test "awaitFrame: a frame this side cannot read is an error, never silence" {
493 l.awaitFrame(testing.allocator, .stats_reply, null, .{}), 501 l.awaitFrame(testing.allocator, .stats_reply, null, .{}),
494 ); 502 );
495 } 503 }
504
505 test "forced child cleanup reaps a peer ignoring SIGTERM" {
506 const a = std.testing.allocator;
507 var child = std.process.Child.init(&.{ "sh", "-c", "trap '' TERM; printf R; while :; do :; done" }, a);
508 child.stdin_behavior = .Pipe;
509 child.stdout_behavior = .Pipe;
510 try child.spawn();
511 defer terminateChild(&child);
512 const pid = child.id;
513 var fds = [_]std.posix.pollfd{.{ .fd = child.stdout.?.handle, .events = std.posix.POLL.IN, .revents = 0 }};
514 if (try std.posix.poll(&fds, 1000) == 0) return error.ChildTimeout;
515 var byte: [1]u8 = undefined;
516 try std.testing.expectEqual(@as(usize, 1), try std.posix.read(child.stdout.?.handle, &byte));
517 const start = std.time.milliTimestamp();
518 terminateChild(&child);
519 try std.testing.expect(std.time.milliTimestamp() - start < 500);
520 try std.testing.expectError(error.ProcessNotFound, std.posix.kill(pid, 0));
521 }
src/server/server.zig
Old New
@@ -1845,6 +1845,23 @@ pub const Server = struct {
1845 defer self.alloc.free(dump); 1845 defer self.alloc.free(dump);
1846 self.replyTo(p, .dump_reply, dump); 1846 self.replyTo(p, .dump_reply, dump);
1847 }, 1847 },
1848 .create_req => {
1849 // Resolution and creation share the daemon thread. A competing
1850 // creator is refused without attaching to or resizing its shell.
1851 const outcome: proto.CreateReply = blk: {
1852 const req = proto.parseCreateReq(frame.payload) catch
1853 break :blk .{ .status = .refused, .reason = "invalid request" };
1854 if (req.cols < min_session_cols or req.rows < min_session_rows or req.cols > proto.max_cols)
1855 break :blk .{ .status = .refused, .reason = "invalid size" };
1856 if (self.sessions.find(req.name) != null)
1857 break :blk .{ .status = .exists, .reason = "session name already exists" };
1858 _ = self.sessions.resolve(self, req.name, req.cols, req.rows) orelse
1859 break :blk .{ .status = .refused, .reason = "session could not be created" };
1860 break :blk .{ .status = .created, .reason = "" };
1861 };
1862 var buf: [proto.create_reply_max_len]u8 = undefined;
1863 self.replyTo(p, .create_reply, proto.encodeCreateReply(&buf, outcome.status, outcome.reason));
1864 },
1848 .end_req => { 1865 .end_req => {
1849 // The asking client is excluded from the "others hold it" 1866 // The asking client is excluded from the "others hold it"
1850 // count; an observer holds no session and excludes nobody. 1867 // count; an observer holds no session and excludes nobody.
src/server/server_test_session.zig
Old New
@@ -1542,6 +1542,51 @@ test "Server: end_req alone on a session ends it at once, and an unknown name is
1542 try std.testing.expectEqualStrings("no such session", v.reason); 1542 try std.testing.expectEqualStrings("no such session", v.reason);
1543 } 1543 }
1544 1544
1545 test "Server: competing explicit creators get one shell and never attach or resize an existing session" {
1546 const alloc = std.testing.allocator;
1547 var td = try h.TestDaemon.init(alloc, "createonly", .{ .shell = "/bin/sh" });
1548 defer td.deinit();
1549 const a = try dial.dial(td.sock_path);
1550 defer a.close();
1551 const b = try dial.dial(td.sock_path);
1552 defer b.close();
1553 var req: [proto.create_req_max_len]u8 = undefined;
1554 const bytes = proto.encodeCreateReq(&req, 80, 24, "fresh");
1555 try proto.writeFrame(a.handle, .create_req, bytes);
1556 try proto.writeFrame(b.handle, .create_req, bytes);
1557 const ra = (try awaitFrame(alloc, &td.srv, a.handle, .create_reply, 400)) orelse return error.NoCreateReply;
1558 defer ra.deinit(alloc);
1559 const rb = (try awaitFrame(alloc, &td.srv, b.handle, .create_reply, 400)) orelse return error.NoCreateReply;
1560 defer rb.deinit(alloc);
1561 const sa = (try proto.parseCreateReply(ra.payload)).status;
1562 const sb = (try proto.parseCreateReply(rb.payload)).status;
1563 try std.testing.expect((sa == .created and sb == .exists) or (sa == .exists and sb == .created));
1564 const si = td.srv.sessions.find("fresh") orelse return error.MissingSession;
1565 const epoch = td.srv.ses(si).epoch;
1566 for (td.srv.clients) |slot| if (slot) |live| try std.testing.expect(live.session != si);
1567
1568 // The common daemon-verb path also handles an attached client (including
1569 // QUIC). A repeated create cannot alter the session it happens to hold.
1570 const attached = try dial.dialAttachNamed(td.sock_path, 80, 24, "fresh");
1571 defer attached.close();
1572 (try awaitFrame(alloc, &td.srv, attached.handle, .snapshot, 400) orelse return error.NoState).deinit(alloc);
1573 try proto.writeFrame(attached.handle, .create_req, proto.encodeCreateReq(&req, 120, 40, "fresh"));
1574 const exists = (try awaitFrame(alloc, &td.srv, attached.handle, .create_reply, 400)) orelse return error.NoCreateReply;
1575 defer exists.deinit(alloc);
1576 try std.testing.expectEqual(proto.CreateStatus.exists, (try proto.parseCreateReply(exists.payload)).status);
1577 try std.testing.expectEqual(epoch, td.srv.ses(si).epoch);
1578 try std.testing.expectEqual(@as(u16, 80), td.srv.colsNow(si));
1579 try std.testing.expectEqual(@as(u16, 24), td.srv.rowsNow(si));
1580
1581 for ([_][]const u8{ &.{}, &.{ 80, 0, 24, 0 }, &.{ 80, 0, 24, 0, '#' }, &.{ 0, 0, 0, 0, 'z' } }) |bad| {
1582 try proto.writeFrame(a.handle, .create_req, bad);
1583 const refused = (try awaitFrame(alloc, &td.srv, a.handle, .create_reply, 400)) orelse return error.NoCreateReply;
1584 defer refused.deinit(alloc);
1585 try std.testing.expectEqual(proto.CreateStatus.refused, (try proto.parseCreateReply(refused.payload)).status);
1586 }
1587 try std.testing.expect(td.srv.sessions.find("z") == null);
1588 }
1589
1545 test "Server: an observer's sessions_req is answered with every live name" { 1590 test "Server: an observer's sessions_req is answered with every live name" {
1546 const alloc = std.testing.allocator; 1591 const alloc = std.testing.allocator;
1547 1592
src/tui/interact.zig
Old New
@@ -1686,6 +1686,8 @@ pub const Core = struct {
1686 .upgrade_req, 1686 .upgrade_req,
1687 .upgrade_reply, 1687 .upgrade_reply,
1688 .end_req, 1688 .end_req,
1689 .create_req,
1690 .create_reply,
1689 => return .skip, 1691 => return .skip,
1690 // MsgType is open (`_`): a daemon newer than this client can 1692 // MsgType is open (`_`): a daemon newer than this client can
1691 // send a type this build has never heard of. Ignoring it is the 1693 // send a type this build has never heard of. Ignoring it is the
test/e2e.sh
Old New
@@ -176,8 +176,8 @@ done
176 # one of those and adds a convergence point would be pinning a fact every 176 # one of those and adds a convergence point would be pinning a fact every
177 # leg above already establishes. 177 # leg above already establishes.
178 178
179 [ "$OK_COUNT" = "114" ] || { 179 [ "$OK_COUNT" = "115" ] || {
180 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 114 —" 180 echo "e2e FAIL: $OK_COUNT scenario checkpoints ran, the pin says 115 —"
181 echo " a scenario was added (update the pin) or silently lost" 181 echo " a scenario was added (update the pin) or silently lost"
182 exit 1 182 exit 1
183 } 183 }
test/e2e_01_boot.sh
Old New
@@ -89,6 +89,39 @@ kill -0 "$D1PID" || { echo "e2e FAIL: daemon died on detach"; exit 1; }
89 # screen the daemon holds — same engine, same formatter, both formats. 89 # screen the daemon holds — same engine, same formatter, both formats.
90 assert_converged "$OUT" "$SOCK" "base attach" 90 assert_converged "$OUT" "$SOCK" "base attach"
91 91
92 # Preloaded stdin belongs to the terminal command loop, not the dial's
93 # cancellation signal. Both local transports must attach/create before the
94 # queued detach is handled; merely changing an early-abort exit code to zero
95 # would still leave the requested session absent.
96 EARLYSOCK="$SOCK.early-detach"
97 EARLYSTATE="$OUT.early.state"
98 defer_sock "$EARLYSOCK"
99 defer_rm "$EARLYSTATE"
100 mkdir -p "$EARLYSTATE"
101 start_daemon "$EARLYSOCK" "$OUT.early.d" "early-detach daemon never bound" --shell /bin/sh
102 XDG_STATE_HOME="$EARLYSTATE" python3 - "$MUX" "$EARLYSOCK" <<'PY'
103 import os
104 import subprocess
105 import sys
106 mux, sock = sys.argv[1:]
107 for name, target in (("early-socket", ["--sock", sock]),
108 ("early-stdio", ["--via", mux + " d proxy --sock " + sock])):
109 reader, writer = os.pipe()
110 os.write(writer, bytes([28]) + b"d")
111 os.close(writer)
112 try:
113 result = subprocess.run([mux, *target, "--session", name], stdin=reader,
114 capture_output=True, timeout=10)
115 finally:
116 os.close(reader)
117 if result.returncode != 0:
118 raise AssertionError((name, result.returncode, result.stderr.decode()))
119 subprocess.run([mux, "a", "status", "--sock", sock, "--session", name,
120 "--timeout", "2000"], stdout=subprocess.DEVNULL,
121 check=True, timeout=5)
122 PY
123 ok "preloaded detach on socket and stdio clients attaches first and preserves the new session"
124
92 # The control: a doctored stream must NOT converge. A convergence check 125 # The control: a doctored stream must NOT converge. A convergence check
93 # that cannot fail proves nothing (the wan.sh rule, M9). 126 # that cannot fail proves nothing (the wan.sh rule, M9).
94 cp "$OUT" "$OUT.doctored" 127 cp "$OUT" "$OUT.doctored"
test/native_picker.py
Old New
@@ -0,0 +1,353 @@
1 #!/usr/bin/env python3
2 """Native picker acceptance through ordinary SDL keys and independent daemon reads."""
3 import os
4 from pathlib import Path
5 import socket
6 import struct
7 import sys
8 import threading
9 import time
10
11 from native_tiling import Rig, colour_counts, eventually, require
12
13
14 def read_exact(stream, count):
15 result = bytearray()
16 while len(result) < count:
17 data = stream.recv(count - len(result))
18 if not data:
19 raise EOFError("daemon closed before replying")
20 result.extend(data)
21 return bytes(result)
22
23
24 def frame(stream):
25 kind, size = struct.unpack("<BI", read_exact(stream, 5))
26 require(size <= 16 * 1024 * 1024, "oversized daemon frame")
27 return kind, read_exact(stream, size)
28
29
30 def send_frame(stream, kind, payload=b""):
31 stream.sendall(struct.pack("<BI", kind, len(payload)) + payload)
32
33
34 def request(sock, kind, payload, expected):
35 with socket.socket(socket.AF_UNIX) as stream:
36 stream.settimeout(3)
37 stream.connect(sock)
38 send_frame(stream, kind, payload)
39 while True:
40 answer, data = frame(stream)
41 if answer == expected:
42 return data
43
44
45 def sessions(sock):
46 return {line for line in request(sock, 0x0c, b"", 0x91).decode().splitlines()
47 if line and not line.startswith("#")}
48
49
50 def create(sock, name):
51 reply = request(sock, 0x12, struct.pack("<HH", 80, 24) + name.encode(), 0x98)
52 require(reply[0] == 0, f"fixture could not create {name}: {reply!r}")
53
54
55 def end(sock, name):
56 reply = request(sock, 0x11, b"\x01" + name.encode(), 0x94)
57 require(reply[0] == 0, f"fixture could not end {name}: {reply!r}")
58 eventually(lambda: name not in sessions(sock), "ended session remained live")
59
60
61 def region(capture, rect):
62 width, height, pixels = capture
63 x, y = rect["x"], rect["y"]
64 right, bottom = min(x + rect["w"], width), min(y + rect["h"], height)
65 return b"".join(pixels[(row * width + x) * 3:(row * width + right) * 3]
66 for row in range(y, bottom))
67
68
69 class SlowHost:
70 """A real socket peer, with optional delayed list and ignored create requests."""
71 def __init__(self, root, label, delay=0):
72 self.path = str(root / (label + ".sock"))
73 self.delay = delay
74 self.requests = []
75 self.stop = threading.Event()
76 self.listener = socket.socket(socket.AF_UNIX)
77 self.listener.bind(self.path)
78 self.listener.listen(8)
79 self.listener.settimeout(0.1)
80 self.workers = []
81 self.thread = threading.Thread(target=self.run)
82 self.thread.start()
83
84 def run(self):
85 while not self.stop.is_set():
86 try:
87 stream, _ = self.listener.accept()
88 except socket.timeout:
89 continue
90 worker = threading.Thread(target=self.serve, args=(stream,))
91 self.workers.append(worker)
92 worker.start()
93
94 def serve(self, stream):
95 with stream:
96 stream.settimeout(0.1)
97 try:
98 # Clients send small complete requests; retain partial frames
99 # across timeouts so the fixture itself cannot lose a header.
100 data = bytearray()
101 while not self.stop.is_set():
102 try:
103 chunk = stream.recv(8192)
104 except socket.timeout:
105 continue
106 if not chunk:
107 return
108 data.extend(chunk)
109 while len(data) >= 5:
110 kind, size = struct.unpack("<BI", data[:5])
111 if len(data) < 5 + size:
112 break
113 payload = bytes(data[5:5 + size])
114 del data[:5 + size]
115 self.requests.append((kind, payload))
116 if kind == 0x0c:
117 if self.stop.wait(self.delay):
118 return
119 send_frame(stream, 0x91, b"legacy")
120 except (OSError, EOFError):
121 pass
122
123 def close(self):
124 self.stop.set()
125 self.thread.join(2)
126 self.listener.close()
127 for worker in self.workers:
128 worker.join(2)
129 require(not self.thread.is_alive() and all(not w.is_alive() for w in self.workers),
130 "slow-host fixture leaked a worker")
131
132
133 def workflow(rig):
134 first, _ = rig.daemon("picker-a")
135 second, _ = rig.daemon("picker-b", quic=True)
136 first_target, second_target = "--sock " + first, rig.targets[second]
137 rig.catalogue([first_target, second_target])
138 initial = rig.launch_gui(["--sock", first, "--session", "left"], "gui-picker")
139 original_layout = [p["outer"] for p in initial["panes"]]
140 before = sessions(second)
141 rig.host(second_target, "v")
142 rig.choose("New session...")
143 rig.picker("session_name")
144 rig.send("text:CANCELLED-NAME", "key:escape", "key:escape", "key:escape")
145 state = rig.wait_state(lambda s: not s.get("picker"))
146 require(sessions(second) == before, "cancelling picker created a session")
147 require([p["outer"] for p in state["panes"]] == original_layout, "cancel changed layout")
148 require(state["pending"] is not None, "closing picker lost armed direction")
149 require("CANCELLED-NAME" not in rig.dump(first, "left"), "editor text leaked to PTY")
150 rig.ok("host/editor back and cancel preserve layout, session table and armed split")
151
152 rig.host(second_target)
153 rig.new_session("right")
154 state = rig.wait_state(lambda s: len(s["panes"]) == 2 and not s.get("picker") and
155 all(p["phase"] == "attached" for p in s["panes"]))
156 rig.shell("printf '\\033[2J\\033[H\\033[38;2;0;255;0mPICKER-%s\\033[0m\\n' RIGHT")
157 rig.wait_marker(second, "right", "PICKER-RIGHT")
158 require("PICKER-RIGHT" not in rig.dump(first, "left"), "new pane input reached old pane")
159 untouched = state["panes"][0]["outer"]
160 rig.host(first_target, "b")
161 rig.choose("0")
162 state = rig.wait_state(lambda s: len(s["panes"]) == 3 and not s.get("picker") and
163 all(p["phase"] == "attached" for p in s["panes"]))
164 require(state["panes"][0]["outer"] == untouched, "nested split rebalanced unrelated pane")
165 rig.shell("printf '\\033[2J\\033[H\\033[31mPICKER-%s\\033[0m\\n' EXISTING")
166 rig.wait_marker(first, "0", "PICKER-EXISTING")
167 rig.wait_state(lambda s: "PICKER-EXISTING" in s["panes"][2]["painted_text"])
168 pixels = rig.pixels()
169 require(colour_counts(pixels, state["panes"][2]["content"], "red")[0] > 30,
170 "existing session glyphs missing from nested pane framebuffer")
171 green = colour_counts(pixels, state["panes"][1]["content"], "green")
172 require(green[0] > 30 and green[1] == 0, "nested split erased or misplaced its neighbour's glyphs")
173 rig.ok("picker creates over QUIC and joins existing session in nested split with real input and pixels")
174
175 rig.host(first_target, "v")
176 rig.choose("New session...")
177 rig.picker("session_name")
178 # One event batch: Enter must use the actual resized window even before
179 # the next paint/observation gets a chance to refresh cached geometry.
180 rig.send("text:no-space-shell", "resize:8x8", "key:enter")
181 rig.wait_state(lambda s: s["width"] == 8 and s["height"] == 8 and
182 s.get("picker") and s["picker"].get("notice"))
183 require("no-space-shell" not in sessions(first), "creation happened before checking split capacity")
184 require(len(rig.state()["panes"]) == 3, "failed tiny split mutated pane tree")
185 rig.send("resize:960x600", "key:escape", "key:escape", "key:escape")
186 rig.wait_state(lambda s: s["width"] == 960 and not s.get("picker"))
187 rig.ok("window becoming too small during name entry refuses split before creating a remote shell")
188
189 # Change the server after the user has already entered a fresh name.
190 rig.host(first_target, "v")
191 rig.choose("New session...")
192 rig.picker("session_name")
193 rig.send("text:raced-name")
194 create(first, "raced-name")
195 old_size = rig.status(first, "raced-name")
196 rig.key("enter")
197 rig.wait_state(lambda s: s.get("picker") and s["picker"].get("notice"))
198 require(len(rig.state()["panes"]) == 3, "duplicate create inserted a pane")
199 now = rig.status(first, "raced-name")
200 require((now["cols"], now["rows"]) == (old_size["cols"], old_size["rows"]),
201 "duplicate create resized existing shell")
202 rig.ok("competing create refuses the name without joining or resizing its existing session")
203 rig.send("key:escape", "key:escape", "key:escape")
204 rig.wait_state(lambda s: not s.get("picker"))
205
206 rig.host(first_target)
207 end(first, "raced-name")
208 rig.choose("raced-name")
209 rig.wait_state(lambda s: not s.get("picker") and len(s["panes"]) == 4 and
210 s["panes"][-1]["phase"] in ("refused", "exited", "failed"))
211 require("raced-name" not in sessions(first), "vanished selection recreated a shell")
212 rig.ok("session disappearing after listing is never recreated by selection")
213 rig.quit()
214 require("right" in sessions(second) and "left" in sessions(first), "GUI close ended a session")
215
216
217 def add_host(rig):
218 first, _ = rig.daemon("add-host-a")
219 second, _ = rig.daemon("add-host-b")
220 first_target, second_target = "--sock " + first, "--sock " + second
221 rig.catalogue([first_target])
222 rig.launch_gui(["--sock", first, "--session", "left"], "gui-add-host")
223 before = rig.pixels()
224 rig.chord("v")
225 rig.chord("enter")
226 state = rig.picker("hosts")
227 rect = state["picker"]["rect"]
228 painted = rig.pixels()
229 require(sum(a != b for a, b in zip(region(before, rect), region(painted, rect))) > 100,
230 "host picker state changed without rendering its popup")
231 rig.choose("Add host...")
232 rig.picker("host_name")
233 rig.send("text:invalid#session", "key:enter")
234 rig.wait_state(lambda s: s.get("picker") and s["picker"].get("notice"))
235 catalogue = Path(rig.env["XDG_STATE_HOME"]) / "mux" / "hosts"
236 require(catalogue.read_text().splitlines() == [first_target], "invalid target changed shared catalogue")
237 rig.key("escape")
238 rig.picker("hosts")
239 rig.choose("Add host...")
240 rig.picker("host_name")
241 rig.send("text:" + second_target, "key:enter")
242 rig.picker("sessions")
243 require(catalogue.read_text().splitlines().count(second_target) == 1,
244 "confirmed Add host did not save exactly one target")
245 rig.new_session("added")
246 rig.wait_state(lambda s: len(s["panes"]) == 2 and all(p["phase"] == "attached" for p in s["panes"]))
247 rig.shell("printf 'ADDED-HOST-%s\\n' LIVE")
248 rig.wait_marker(second, "added", "ADDED-HOST-LIVE")
249 require("ADDED-HOST-LIVE" not in rig.dump(first, "left"), "Add host editor leaked into existing terminal")
250 rig.quit()
251 rig.ok("Add host validates spelling, saves shared catalogue, and inserts a working session")
252
253
254 def slow_and_legacy(rig):
255 first, _ = rig.daemon("picker-responsive")
256 slow = SlowHost(rig.root, "delayed-list", delay=2)
257 legacy = SlowHost(rig.root, "legacy-create")
258 try:
259 first_target = "--sock " + first
260 slow_target, legacy_target = "--sock " + slow.path, "--sock " + legacy.path
261 rig.catalogue([first_target, slow_target, legacy_target])
262 rig.launch_gui(["--sock", first, "--session", "left"], "gui-slow-picker")
263 rig.chord("v")
264 rig.chord("enter")
265 rig.picker("hosts")
266 rig.choose(slow_target)
267 eventually(lambda: any(kind == 0x0c for kind, _ in slow.requests), "slow host was never queried")
268 started = time.monotonic()
269 rig.key("escape")
270 rig.picker("hosts")
271 rig.choose(first_target)
272 rig.picker("sessions")
273 require(time.monotonic() - started < 1, "slow host blocked choosing another host")
274 # Let the old request finish after the target has changed.
275 time.sleep(2.1)
276 state = rig.picker("sessions")
277 labels = [r["label"] if isinstance(r, dict) else r for r in state["picker"]["rows"]]
278 require("legacy" not in labels and "left" in labels, "late response replaced the current host's sessions")
279 rig.send("key:escape", "key:escape")
280 rig.wait_state(lambda s: not s.get("picker"))
281 rig.shell("printf 'AFTER-CANCEL-%s\\n' LIVE")
282 rig.wait_marker(first, "left", "AFTER-CANCEL-LIVE")
283 rig.wait_state(lambda s: "AFTER-CANCEL-LIVE" in s["panes"][0]["painted_text"])
284 rig.ok("slow discovery cancels promptly; stale replies cannot replace a new host or block terminal painting")
285
286 rig.host(legacy_target)
287 rig.new_session("never-fallback")
288 rig.wait_state(lambda s: s.get("picker") and s["picker"].get("notice"), seconds=20)
289 require(len(rig.state()["panes"]) == 1, "unanswered creation inserted a pane")
290 require(sum(kind == 0x12 for kind, _ in legacy.requests) == 1,
291 "creation was not submitted exactly once")
292 require(not any(kind == 0x01 for kind, _ in legacy.requests),
293 "old daemon was sent an unsafe attach fallback")
294 rig.ok("older daemon's unanswered create reports uncertainty without retry or attach fallback")
295 rig.send("key:escape", "key:escape", "key:escape")
296 rig.wait_state(lambda s: not s.get("picker"))
297 rig.chord("enter")
298 rig.picker("hosts")
299 before_requests = sum(kind == 0x0c for kind, _ in slow.requests)
300 rig.choose(slow_target)
301 eventually(lambda: sum(kind == 0x0c for kind, _ in slow.requests) > before_requests,
302 "shutdown test never dispatched its slow discovery request")
303 started = time.monotonic()
304 rig.quit()
305 require(time.monotonic() - started < 1, "GUI shutdown waited for slow discovery")
306 require("left" in sessions(first), "shutdown ended live terminal session")
307 rig.ok("window closes during discovery without waiting for the remote reply or ending sessions")
308 finally:
309 slow.close()
310 legacy.close()
311
312
313 def old_daemon(rig, binary):
314 current = rig.mux
315 try:
316 rig.mux = str(Path(binary).resolve())
317 first, _ = rig.daemon("prior-sprint-daemon")
318 finally:
319 rig.mux = current
320 target = "--sock " + first
321 rig.catalogue([target])
322 rig.launch_gui(["--sock", first, "--session", "left"], "gui-old-daemon")
323 rig.host(target, "v")
324 rig.choose("0")
325 rig.wait_state(lambda s: len(s["panes"]) == 2 and all(p["phase"] == "attached" for p in s["panes"]))
326 rig.shell("printf 'OLD-DAEMON-%s\\n' JOINED")
327 rig.wait_marker(first, "0", "OLD-DAEMON-JOINED")
328 rig.host(target, "b")
329 rig.new_session("unsupported-create")
330 rig.wait_state(lambda s: s.get("picker") and s["picker"].get("notice"), seconds=20)
331 require("unsupported-create" not in sessions(first), "old daemon fallback created a shell")
332 rig.quit()
333 rig.ok("actual prior-sprint daemon supports join-only selection and safely leaves new creation unanswered")
334
335
336 def main():
337 rig = Rig(*sys.argv[1:3])
338 try:
339 workflow(rig)
340 add_host(rig)
341 slow_and_legacy(rig)
342 if binary := os.environ.get("MUXG_OLD_DAEMON"):
343 old_daemon(rig, binary)
344 print(f"native picker OK ({rig.checkpoints} checkpoints); artifacts: {rig.root}")
345 except BaseException:
346 print("Native picker failure artifacts:", rig.root, file=sys.stderr)
347 raise
348 finally:
349 rig.close()
350
351
352 if __name__ == "__main__":
353 main()
test/native_tiling.py
Old New
@@ -104,16 +104,18 @@ class Rig:
104 return sock, proc 104 return sock, proc
105 105
106 def start_gui(self, first, second, label): 106 def start_gui(self, first, second, label):
107 self.catalogue(["--sock " + first, self.targets.get(second, "--sock " + second)])
108 return self.launch_gui(["--sock", first, "--session", "left"], label)
109
110 def launch_gui(self, args, label, attached=True):
107 fifo = self.root / (label + ".fifo") 111 fifo = self.root / (label + ".fifo")
108 os.mkfifo(fifo) 112 os.mkfifo(fifo)
109 self.fd = os.open(fifo, os.O_RDWR | os.O_NONBLOCK) 113 self.fd = os.open(fifo, os.O_RDWR | os.O_NONBLOCK)
110 self.env["MUXG_TEST_FIFO"] = str(fifo) 114 self.env["MUXG_TEST_FIFO"] = str(fifo)
111 self.gui_log = self.root / (label + ".log") 115 self.gui_log = self.root / (label + ".log")
112 self.gui = self.spawn([self.muxg, "--sock", first, "--session", "left", 116 self.gui = self.spawn([self.muxg, *args], label)
113 "--next-target", self.targets.get(second, "--sock " + second),
114 "--next-session", "right"], label)
115 return self.wait_state(lambda s: len(s["panes"]) == 1 and 117 return self.wait_state(lambda s: len(s["panes"]) == 1 and
116 s["panes"][0]["phase"] == "attached") 118 (not attached or s["panes"][0]["phase"] == "attached"))
117 119
118 def send(self, *lines): 120 def send(self, *lines):
119 require(self.gui.poll() is None, "GUI exited unexpectedly") 121 require(self.gui.poll() is None, "GUI exited unexpectedly")
@@ -141,11 +143,48 @@ class Rig:
141 def state(self): 143 def state(self):
142 return json.loads(self.artifact("state", ".json").read_text()) 144 return json.loads(self.artifact("state", ".json").read_text())
143 145
144 def wait_state(self, predicate): 146 def wait_state(self, predicate, seconds=DEADLINE):
145 def probe(): 147 def probe():
146 state = self.state() 148 state = self.state()
147 return state if predicate(state) else None 149 return state if predicate(state) else None
148 return eventually(probe, "GUI state did not converge") 150 return eventually(probe, "GUI state did not converge", seconds=seconds)
151
152 def catalogue(self, targets):
153 path = Path(self.env["XDG_STATE_HOME"]) / "mux" / "hosts"
154 path.parent.mkdir(exist_ok=True)
155 path.write_text("\n".join(targets) + "\n")
156
157 def picker(self, level=None):
158 return self.wait_state(lambda s: s.get("picker") and
159 (level is None or s["picker"]["level"] == level))
160
161 def choose(self, label):
162 def present():
163 state = self.state()
164 picker = state.get("picker")
165 if not picker:
166 return None
167 labels = [row["label"] if isinstance(row, dict) else row for row in picker["rows"]]
168 matches = [i for i, text in enumerate(labels) if text == label]
169 return (matches[0], picker["selected"]) if len(matches) == 1 else None
170 chosen, selected = eventually(present, f"picker row {label!r} not found uniquely", seconds=20)
171 delta = chosen - selected
172 for _ in range(abs(delta)):
173 self.key("down" if delta > 0 else "up")
174 self.key("enter")
175
176 def host(self, spelling, axis=None):
177 if axis:
178 self.chord(axis)
179 self.chord("enter")
180 self.picker("hosts")
181 self.choose(spelling)
182 self.picker("sessions")
183
184 def new_session(self, name):
185 self.choose("New session...")
186 self.picker("session_name")
187 self.send("text:" + name, "key:enter")
149 188
150 def pixels(self): 189 def pixels(self):
151 raw = self.artifact("capture", ".ppm").read_bytes() 190 raw = self.artifact("capture", ".ppm").read_bytes()
@@ -255,13 +294,11 @@ def normal_scenario(rig, axis):
255 first, _ = rig.daemon("first-" + axis) 294 first, _ = rig.daemon("first-" + axis)
256 second, second_proc = rig.daemon("second-" + axis, quic=axis == "b") 295 second, second_proc = rig.daemon("second-" + axis, quic=axis == "b")
257 if axis == "v": 296 if axis == "v":
258 for flags in (("--next-target", "box"), ("--next-session", "right"), 297 for flags in (("--next-target", "box"), ("--next-session", "right")):
259 ("--next-target", "box", "--next-session", "bad name"),
260 ("--next-target", "box", "--next-target", "other", "--next-session", "right")):
261 result = subprocess.run([rig.muxg, "--sock", first, *flags], env=rig.env, 298 result = subprocess.run([rig.muxg, "--sock", first, *flags], env=rig.env,
262 capture_output=True, timeout=3) 299 capture_output=True, timeout=3)
263 require(result.returncode == 2, "invalid staged-target arguments were accepted") 300 require(result.returncode == 2, "retired staged-target arguments were accepted")
264 rig.ok("staged target arguments reject missing partners, invalid sessions and repeated targets") 301 rig.ok("retired staged-target flags are rejected; picker supplies insertion targets")
265 before = rig.start_gui(first, second, "gui-" + axis) 302 before = rig.start_gui(first, second, "gui-" + axis)
266 rig.chord(axis) 303 rig.chord(axis)
267 armed = rig.state() 304 armed = rig.state()
@@ -269,7 +306,7 @@ def normal_scenario(rig, axis):
269 "arming split changed geometry") 306 "arming split changed geometry")
270 stats = rig.command("d", "stats", "--sock", second).stdout 307 stats = rig.command("d", "stats", "--sock", second).stdout
271 require(re.search(r"\battaches=0\b", stats), 308 require(re.search(r"\battaches=0\b", stats),
272 "staged target attached before insertion: " + stats) 309 "picker target attached before insertion: " + stats)
273 # Daemons may start a default session themselves. The staged named session 310 # Daemons may start a default session themselves. The staged named session
274 # must still be absent, and this observer query cannot create one. 311 # must still be absent, and this observer query cannot create one.
275 require(rig.command("a", "status", "--sock", second, "--session", "right", 312 require(rig.command("a", "status", "--sock", second, "--session", "right",
@@ -279,6 +316,10 @@ def normal_scenario(rig, axis):
279 require(len(rig.state()["panes"]) == 1, "cancel inserted a pane") 316 require(len(rig.state()["panes"]) == 1, "cancel inserted a pane")
280 rig.chord(axis) 317 rig.chord(axis)
281 rig.chord("enter") 318 rig.chord("enter")
319 rig.picker("hosts")
320 rig.choose(rig.targets.get(second, "--sock " + second))
321 rig.picker("sessions")
322 rig.new_session("right")
282 state = rig.wait_state(lambda s: len(s["panes"]) == 2 and all(p["phase"] == "attached" for p in s["panes"])) 323 state = rig.wait_state(lambda s: len(s["panes"]) == 2 and all(p["phase"] == "attached" for p in s["panes"]))
283 check_geometry(rig, state, first, second, axis) 324 check_geometry(rig, state, first, second, axis)
284 transport = "Unix + QUIC" if axis == "b" else "two Unix sockets" 325 transport = "Unix + QUIC" if axis == "b" else "two Unix sockets"
@@ -379,21 +420,15 @@ def normal_scenario(rig, axis):
379 420
380 421
381 def unavailable_scenario(rig): 422 def unavailable_scenario(rig):
382 first, _ = rig.daemon("first-unavailable")
383 missing = str(rig.root / "missing.sock") 423 missing = str(rig.root / "missing.sock")
384 rig.start_gui(first, missing, "gui-unavailable") 424 rig.launch_gui(["--sock", missing, "--session", "offline"], "gui-unavailable", attached=False)
385 rig.chord("v") 425 rig.wait_state(lambda s: s["panes"][0]["phase"] in ("dial_failed", "failed", "reconnecting"))
386 rig.chord("enter")
387 rig.wait_state(lambda s: len(s["panes"]) == 2 and s["panes"][1]["phase"] in ("dial_failed", "failed", "reconnecting"))
388 rig.chord("h")
389 rig.shell("printf 'OFFLINE-NEIGHBOUR-%s\\n' OK")
390 rig.wait_marker(first, "left", "OFFLINE-NEIGHBOUR-OK")
391 rig.send("resize:8x8") 426 rig.send("resize:8x8")
392 state = rig.wait_state(lambda s: s["width"] == 8 and s["height"] == 8) 427 state = rig.wait_state(lambda s: s["width"] == 8 and s["height"] == 8)
393 require(len(state["panes"]) == 2 and all(p["cols"] > 0 and p["rows"] > 0 for p in state["panes"]), 428 require(len(state["panes"]) == 1 and all(p["cols"] > 0 and p["rows"] > 0 for p in state["panes"]),
394 "tiny window destroyed panes or sent invalid sizes") 429 "tiny window destroyed panes or sent invalid sizes")
395 rig.quit() 430 rig.quit()
396 rig.ok("initially unreachable target stays local to its pane; tiny window and shutdown remain safe") 431 rig.ok("initially unreachable target retains its pane; tiny window and shutdown remain safe")
397 432
398 433
399 def main(): 434 def main():