67a1e976
feat: add native host and session pickers
a73x 2026-09-05 08:55
Commit message
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(): |