a73x

04437bf9

feat: restore native workspaces and manage pane lifecycle

a73x   2026-09-05 10:54

Commit message
feat: restore native workspaces and manage pane lifecycle

CLAUDE.md
Old New
@@ -79,7 +79,7 @@ a symbol by its FILE stem (`wall_pump.askOn`) — a file, not a module.
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` `buffered_wire` `open_wait` `resolver` `discovery` · `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` `picker` `font` `atlas` `quads` `gl` `frame` `bench` | 82 | `src/gui/` | `native`(`native.zig`) — `workspace` `runtime` `picker` `persistence` `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,12 +26,13 @@ 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 pane, picker and resize integration checks; needs python3 29 make native-e2e # ReleaseSafe pane, picker, resize and restore 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
33 `muxg` displays daemon sessions in native terminal panes. With no target it uses 33 `muxg` displays daemon sessions in native terminal panes. With no arguments it
34 the local socket; start its daemon with `mux d start -d`. It never starts a local 34 restores the saved native workspace; first launch opens the host/session picker.
35 For local sessions, start the daemon with `mux d start -d`. It never starts a local
35 daemon itself, and an unavailable target is shown in its pane. A remote `HOST` uses mux's SSH 36 daemon itself, and an unavailable target is shown in its pane. A remote `HOST` uses mux's SSH
36 handoff, including that handoff's own remote-start behavior. `--via` uses a 37 handoff, including that handoff's own remote-start behavior. `--via` uses a
37 command's stdio, and `quic://HOST[:PORT]` uses `--key` or `MUX_KEY_FILE`. 38 command's stdio, and `quic://HOST[:PORT]` uses `--key` or `MUX_KEY_FILE`.
@@ -47,9 +48,10 @@ 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 48 from OpenGL, and measures a 20 ms p99 window-side budget under concurrent
48 output; pump apply time is reported separately. 49 output; pump apply time is reported separately.
49 50
50 Open the initial terminal, then use the native picker to add more panes: 51 Open the saved workspace, or an explicit target in a temporary workspace:
51 52
52 ```sh 53 ```sh
54 ./zig-out/bin/muxg
53 ./zig-out/bin/muxg alpha --session work 55 ./zig-out/bin/muxg alpha --session work
54 ``` 56 ```
55 57
@@ -80,10 +82,23 @@ Drag a divider to resize its panes, or press the prefix then **r** for resize mo
80 direction by one terminal cell; **Enter** or **Esc** exits. Pane minimum sizes 82 direction by one terminal cell; **Enter** or **Esc** exits. Pane minimum sizes
81 limit movement. Window resizing preserves the chosen proportions. 83 limit movement. Window resizing preserves the chosen proportions.
82 84
83 Saved native layouts follow in a later sprint. Tabs are represented in the 85 Prefix then **d** detaches and removes the focused pane, preserving its session.
84 ownership model but have no UI yet. Native 86 Prefix then **x** asks the daemon to end that session. If other clients are
85 layout choices are independent of terminal mux; both clients use the same daemon 87 attached, a confirmation offers **End for all clients**, with **Cancel** selected.
86 sessions. 88 Prefix then **p** offers **Retry**, **Choose session** in the same pane, and **Detach**.
89 Missing and exited sessions stay visible until an explicit action; restore never
90 creates a replacement shell. Unreachable hosts retry with backoff while other
91 panes remain usable. Removing the last pane leaves an Add pane surface.
92
93 The default workspace saves to `$XDG_STATE_HOME/mux/native-workspace.json`
94 (or `~/.local/state/mux/native-workspace.json`). It remembers pane identities,
95 targets, focus, split directions, and relative sizes. Explicit target/session
96 launches are temporary and leave that saved workspace alone. One window may own
97 the default workspace at a time. Save failures appear in the GUI; unreadable or
98 malformed saved state is preserved with saving disabled for that run.
99
100 Tabs are represented in the saved model but have no UI yet. Native layout choices
101 are independent of terminal mux; both clients use the same daemon sessions.
87 102
88 Binaries land in `zig-out/bin/`. For remote machines, build a static binary 103 Binaries land in `zig-out/bin/`. For remote machines, build a static binary
89 that runs on any x86_64 Linux: 104 that runs on any x86_64 Linux:
RETRO.md
Old New
@@ -184,18 +184,102 @@ before Sprint 4 and close each with evidence at its retrospective.
184 - [x] Obtain resize demo acceptance and resolve any requested ergonomic changes 184 - [x] Obtain resize demo acceptance and resolve any requested ergonomic changes
185 before extending interaction with detach, end, and recovery actions. 185 before extending interaction with detach, end, and recovery actions.
186 The user approved the recorded demo on 2026-09-05; no changes were requested. 186 The user approved the recorded demo on 2026-09-05; no changes were requested.
187 - [ ] Start with a bounded cleanup of the lifecycle and state-ownership paths; 187 - [x] Start with a bounded cleanup of the lifecycle and state-ownership paths;
188 keep substantial refactors explicit and preserve the closing cleanup package. 188 keep substantial refactors explicit and preserve the closing cleanup package.
189 - [ ] Persist the relative weights and stable pane/tab identity introduced here; 189 Sprint 4 opening cleanup `aa5e977` centralizes attachment startup/teardown;
190 review, 33 native tests, and `make check` passed. Closing cleanup remains open.
191 - [x] Persist the relative weights and stable pane/tab identity introduced here;
190 round-trip nested layouts after minimum clamping and at a different display scale. 192 round-trip nested layouts after minimum clamping and at a different display scale.
191 - [ ] Validate detach versus end through independently observed daemon sessions, 193 Nested weights/identities survived 8x8 clamping and actual 100% save to 200%
194 Sway restore; `/tmp/muxg-lifecycle-wayland.log` records the 13 passing scenarios.
195 - [x] Validate detach versus end through independently observed daemon sessions,
192 including missing/offline hosts and sessions shared by another client. 196 including missing/offline hosts and sessions shared by another client.
193 - [ ] Exercise atomic-save failure, malformed-state preservation, stale asynchronous 197 The lifecycle harness checks real daemon outcomes, including Cancel/default
198 refusal, sole attachment End, explicit force, and detach survival. Real SSH/QUIC
199 restore and lifecycle checks passed separately in the user-provided VM.
200 - [x] Exercise atomic-save failure, malformed-state preservation, stale asynchronous
194 completions, and cancellation during restoration; do not modify terminal mux's 201 completions, and cancellation during restoration; do not modify terminal mux's
195 layout state or silently recreate a missing saved session. 202 layout state or silently recreate a missing saved session.
196 - [ ] Keep observations passive and final gates on frozen source. Retain the real 203 Real chmod failures preserve prior bytes; malformed/unreadable files remain
204 sealed for the run. The harness covers stalled restores, missing sessions,
205 in-place replacement, changed working directories, and a CLI-layout sentinel.
206 - [x] Keep observations passive and final gates on frozen source. Retain the real
197 screen-recording/private Serve demo route documented in 207 screen-recording/private Serve demo route documented in
198 [the sprint workflow](docs/native-sprint-workflow.md). 208 [the sprint workflow](docs/native-sprint-workflow.md).
209 The final source is reviewed and frozen; the 45-second actual Sway recording is
210 privately available at `https://charizard.folk-amberjack.ts.net/sprint4/`.
199 211
200 Relative weights remain in memory until Sprint 4 persistence, whose implementer 212 Relative weights remain in memory until Sprint 4 persistence, whose implementer
201 owns that planned work. No new correctness debt was deferred by the closing review. 213 owns that planned work. No new correctness debt was deferred by the closing review.
214
215 ## Native tiling — Sprint 4, 2026-09-05
216
217 Implementation, adversarial review, and delivery validation are complete. The
218 45-second Sway demo shows restore, a target returning, missing-session recovery,
219 Detach and End. Evidence is tracked in the native tiling plan. User acceptance of
220 Sprint 4 has not yet been given.
221
222 ### What worked
223
224 - The opening cleanup `aa5e977` gave new and restored attachments a shared owned
225 lifetime. Closing cleanup then consolidated add/restore startup, modal held-key
226 ownership, popup reset, and observation of End outcomes through captured status.
227 Both cleanup passes remained bounded; no unrelated CLI layout work was needed.
228 - Agreeing the persistence boundary early let lifecycle/UI work, serialization,
229 and an independent real-session harness progress without competing file edits.
230 Strict graph validation and allocation-failure tests protect partial restores;
231 a stable sibling lock survives atomic replacement of the state file.
232 - Direct review caught mistakes that happy-path demos would miss: retry losing
233 old content after a malformed first snapshot, unreachable SSH appearing
234 permanently failed, modal overlap after a delayed End refusal, and requiring a
235 local runtime directory for an independent remote workspace.
236 - Independent acceptance checks actual daemon sessions, kernel PTY dimensions,
237 retained rendered pixels, saved bytes, process shutdown, and target identity
238 after changing working directories. It passed offscreen and on real Sway at
239 200%, including restoration from 100%. The VM checks exercise real SSH fallback
240 and direct QUIC rather than a transport stub.
241
242 ### What to change
243
244 - Root's final review found an additional destructive-action context problem
245 after the first source freeze: moving focus while waiting could make a delayed
246 force confirmation ambiguous. The final fix binds unsolicited confirmation to
247 the original focused pane and names its captured session/target. Include focus
248 movement among delayed-action acceptance scenarios before the next freeze.
249 - Treat test observation as an explicit design choice. Tiny-window reflow may
250 legitimately move old text into scrollback; compare restored painted output
251 against the daemon's current grid before sending new input. Assertions about
252 captured text should not accidentally require unrelated scrollback behavior.
253 - Proposed `p` recovery and `x` confirmation controls were shared before UI work;
254 implementation proceeded with the stated defaults while awaiting feedback.
255 Recorded acceptance will not be described as a hands-on ergonomic trial.
256
257 ### Retained limits and debt
258
259 The native state schema is tabs-aware, while this milestone deliberately supports
260 one tab and one default-workspace writer. Explicit targets remain temporary.
261 These are planned scope boundaries, not incomplete persistence behavior.
262
263 `src/client/session_pump.zig` retries SSH announce EOF with bounded backoff because
264 the handoff layer uses that same error for unavailable hosts and some SSH
265 authentication failures. The actual reason stays visible; no session creation or
266 End is retried. A future shared handoff error-classification change should separate
267 those cases; Sprint 5's final reviewer owns deciding whether that is justified
268 within the release review or should remain an explicit follow-up.
269
270 ### Actions for Sprint 5
271
272 Owner: the sprint lead and implementation/review pair. Close each with evidence
273 at the final milestone retrospective.
274
275 - [ ] Obtain explicit Sprint 4 demo acceptance before beginning the release review.
276 - [ ] Start with a bounded review/cleanup of the accumulated native workflow, then
277 retain a closing cleanup pass; avoid introducing tabs UI or terminal tiling changes.
278 - [ ] Review delayed destructive actions across focus, picker, resize, drag,
279 detach, and replacement transitions; keep confirmation target identity visible.
280 - [ ] Review the retained SSH EOF/authentication classification limit and record
281 the disposition without weakening bounded reconnect or join-only restoration.
282 - [ ] Run the complete multi-host workflow and final adversarial review, including
283 shared sessions, state ownership/failures, high DPI, and cleanup of owned fixtures.
284 - [ ] Record final demo acceptance separately from automation and any hands-on
285 ergonomic feedback, update the spec's completion status, and commit the milestone.
build.zig
Old New
@@ -326,7 +326,7 @@ const mod_table = [_]ModSpec{
326 // Opt-in rows: no default artifact or test names them, so machines 326 // Opt-in rows: no default artifact or test names them, so machines
327 // without the viewer's system libraries keep all existing gates. 327 // without the viewer's system libraries keep all existing gates.
328 .{ .name = "native", .path = "src/gui/native.zig", .link_libc = true, .imports = &.{ "client", "term" }, .opt_in = true }, 328 .{ .name = "native", .path = "src/gui/native.zig", .link_libc = true, .imports = &.{ "client", "term" }, .opt_in = true },
329 .{ .name = "muxg", .path = "src/cli/muxg.zig", .link_libc = true, .imports = &.{ "native", "client", "term", "cliflags", "sockpath" }, .opt_in = true }, 329 .{ .name = "muxg", .path = "src/cli/muxg.zig", .link_libc = true, .imports = &.{ "native", "client", "term", "cliflags", "sockpath", "xdg" }, .opt_in = true },
330 }; 330 };
331 331
332 /// Comptime row lookup. Every hand-written module name in this file goes 332 /// Comptime row lookup. Every hand-written module name in this file goes
@@ -1173,8 +1173,12 @@ pub fn build(b: *std.Build) void {
1173 native_resize.addArtifactArg(mux_exe); 1173 native_resize.addArtifactArg(mux_exe);
1174 native_resize.addArtifactArg(muxg_exe); 1174 native_resize.addArtifactArg(muxg_exe);
1175 native_resize.step.dependOn(&native_picker.step); 1175 native_resize.step.dependOn(&native_picker.step);
1176 const native_lifecycle = b.addSystemCommand(&.{ "python3", "-B", "test/native_lifecycle.py" });
1177 native_lifecycle.addArtifactArg(mux_exe);
1178 native_lifecycle.addArtifactArg(muxg_exe);
1179 native_lifecycle.step.dependOn(&native_resize.step);
1176 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)"); 1180 const native_e2e_step = b.step("native-e2e", "Run the native client's end-to-end leg (opt-in)");
1177 native_e2e_step.dependOn(&native_resize.step); 1181 native_e2e_step.dependOn(&native_lifecycle.step);
1178 1182
1179 const soak = b.addSystemCommand(&.{"test/soak.sh"}); 1183 const soak = b.addSystemCommand(&.{"test/soak.sh"});
1180 // The same list the e2e step passes, in the same order: soak IS that 1184 // The same list the e2e step passes, in the same order: soak IS that
docs/native-sprint-workflow.md
Old New
@@ -34,6 +34,13 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan
34 the state under test. Use real Wayland at high DPI and a scale transition; 34 the state under test. Use real Wayland at high DPI and a scale transition;
35 exercise SSH/QUIC when changes affect their behavior. Create and clean up only 35 exercise SSH/QUIC when changes affect their behavior. Create and clean up only
36 owned fixture sessions and daemons. 36 owned fixture sessions and daemons.
37
38 For lifecycle work, independently observe the daemon after Detach and End,
39 including another attached client. Change focus and open another modal while
40 a destructive request is pending; a later confirmation must still identify
41 its original target. For persistence, retain original bytes across real
42 permission failures, reopen from a different working directory and scale, and
43 keep a sentinel in the terminal client's layout file.
37 7. **Refactor the integrated result.** Reserve this as work, even after the opening 44 7. **Refactor the integrated result.** Reserve this as work, even after the opening
38 cleanup. Remove duplication, superseded paths, unnecessary state, and temporary 45 cleanup. Remove duplication, superseded paths, unnecessary state, and temporary
39 scaffolding exposed by implementation. Review the result again. For retained 46 scaffolding exposed by implementation. Review the result again. For retained
@@ -63,6 +70,10 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan
63 before closing the window so another application cannot enter the recording. 70 before closing the window so another application cannot enter the recording.
64 A slideshow of screenshots is useful evidence but is not a screen recording. 71 A slideshow of screenshots is useful evidence but is not a screen recording.
65 72
73 For a close/reopen demo, stop the recorder before closing the first window and
74 resume on the new owned window after launch. Join those continuous clips and
75 disclose the restart cuts; do not capture the intervening desktop.
76
66 For mouse acceptance, include the cursor with `grim -c` and drive the 77 For mouse acceptance, include the cursor with `grim -c` and drive the
67 compositor's virtual-pointer motion/button events. SDL-injected input can 78 compositor's virtual-pointer motion/button events. SDL-injected input can
68 validate application routing but does not demonstrate the compositor input 79 validate application routing but does not demonstrate the compositor input
docs/superpowers/plans/2026-09-05-native-tiling.md
Old New
@@ -1,8 +1,8 @@
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 3 complete; implementation, review, validation, and recorded-demo 4 Status: Sprint 4 implemented, reviewed, and validated; recorded-demo acceptance
5 acceptance passed. 5 pending. Sprint 3 acceptance passed.
6 6
7 ## Working agreement 7 ## Working agreement
8 8
@@ -222,6 +222,19 @@ Do not dispatch them while sprint 1 is still under integration or ergonomic revi
222 | 4D: refactor and accept | Deduplicate live/restore attachment lifecycle, simplify save/load ownership, and remove superseded recovery paths | Reviewed cleanup/debt record; rerun lifecycle and persistence acceptance before commit | 222 | 4D: refactor and accept | Deduplicate live/restore attachment lifecycle, simplify save/load ownership, and remove superseded recovery paths | Reviewed cleanup/debt record; rerun lifecycle and persistence acceptance before commit |
223 | 5: final cleanup and review | Review accumulated debt across sprints, finish justified cross-package simplification, then run the complete workflow and final adversarial review | Multi-host acceptance, resource cleanup, persistence failures, high DPI and final ergonomic trial on final code; runnable build and commit | 223 | 5: final cleanup and review | Review accumulated debt across sprints, finish justified cross-package simplification, then run the complete workflow and final adversarial review | Multi-host acceptance, resource cleanup, persistence failures, high DPI and final ergonomic trial on final code; runnable build and commit |
224 224
225 Sprint 4 began on 2026-09-05. Opening cleanup `aa5e977` centralizes native
226 attachment startup and teardown in `Live.start`/`Live.destroy`, preserving the
227 broadcast-before-join shutdown ordering. Independent review, 33 native tests,
228 and `make check` passed before the separate commit. Logs:
229 `/tmp/muxg-lifecycle-baseline-test.log` and
230 `/tmp/muxg-lifecycle-baseline-check.log`.
231
232 Work ownership: the implementation agent owns lifecycle, recovery UI, and restore
233 integration; root owns the independent persistence module and integration; the
234 adversarial reviewer reviews both boundaries; the acceptance agent owns real
235 session/filesystem integration scenarios. The closing cleanup and recorded-demo
236 acceptance are still required.
237
225 Package 2A must include cancellation during transport opening as well as request 238 Package 2A must include cancellation during transport opening as well as request
226 handling. Current `listSessions`/`endSession` paths open with no abort FD and start 239 handling. Current `listSessions`/`endSession` paths open with no abort FD and start
227 reply deadlines after dialing. Add narrow cancellable API support where necessary; 240 reply deadlines after dialing. Add narrow cancellable API support where necessary;
@@ -479,3 +492,67 @@ remove only its route with `tailscale serve --https=443 --set-path /sprint3/mous
479 before stopping that owned server. No hands-on or remote-specific resize trial is 492 before stopping that owned server. No hands-on or remote-specific resize trial is
480 claimed. Sprint 4 retains lifecycle/recovery and native persistence scope; its 493 claimed. Sprint 4 retains lifecycle/recovery and native persistence scope; its
481 opening actions are recorded in RETRO.md. 494 opening actions are recorded in RETRO.md.
495
496 ## Sprint 4 delivery evidence
497
498 Implemented lifecycle/recovery, native persistence, and restore integration.
499 Opening cleanup is `aa5e977`. Closing cleanup shares attachment startup for add
500 and restore, consolidates modal held-key ownership and popup reset, and consumes
501 End outcomes through Runtime's captured status. No unresolved production review
502 finding remains. Recorded-demo acceptance is pending; Sprint 5 has not started.
503
504 The native controls are prefix `d` to detach, `x` to request End, and `p` for
505 Retry/Choose session/Detach. End travels over the pane's own connection, respecting
506 the daemon's other-client refusal. Force requires a separate confirmation with
507 Cancel selected. Delayed results retain the originating pane/generation; a force
508 prompt names its session/target and does not appear after focus moves elsewhere.
509 Unknown End outcomes are not automatically retried. Retry and in-place replacement
510 keep pane identity and layout while advancing attachment generation.
511
512 No-argument launches restore `native-workspace.json`; first launch opens the
513 picker. The default writer has an exclusive sibling lock, and explicit-target
514 workspaces leave its file alone. Strict decoding preserves malformed/unreadable
515 files for the whole run. Atomic saves retain stable IDs, targets, focus and split
516 weights. Restored and replacement attachments are join-only; missing sessions are
517 never silently recreated. Offline targets retry independently with bounded backoff.
518
519 Validation evidence:
520
521 - Final ReleaseSafe build and **43 native tests**:
522 `/tmp/muxg-lifecycle-target-context-build.log`. Shared client tests **215/215**:
523 `/tmp/muxg-picker-client-test.log`.
524 - Complete native acceptance: **10 viewer + 14 tiling + 10 picker + 8 resize +
525 13 lifecycle** scenarios. Initial final run:
526 `/tmp/muxg-sprint4-native-e2e-final.log`; final confirmation-context revision:
527 `/tmp/muxg-sprint4-native-e2e-final-context.log`.
528 - Independent lifecycle rerun on the final binary: **13/13**,
529 `/tmp/muxg-sprint4-lifecycle-final.log`. Its observations include real daemon
530 sessions, kernel PTY dimensions, last-rendered pixels, saved bytes, and a
531 terminal-layout sentinel. Real 100% save to Sway 200% restore also passed all
532 13 scenarios: `/tmp/muxg-lifecycle-wayland.log`.
533 - Real VM SSH fallback and direct QUIC: **3/3**, repeated on the final binary in
534 `/tmp/muxg-sprint4-remote/final-validation.log`. Saved targets restored after
535 catalogue removal; independent remote kernel PTY sizes matched panes; End
536 removed the SSH session and Detach preserved the QUIC session. Only this run's
537 remote fixture directories/daemons were removed; the VM remains running.
538 - **`make ci` passed**: `/tmp/muxg-sprint4-ci-final.log`, including 115 end-to-end
539 scenarios/38 convergence points and 10 agent scenarios. Throughput measured
540 7 ms solo, 53 ms attached, and 92 ms detached repaint. The last native-only
541 confirmation change is covered by the subsequent native build/gates.
542
543 The actual Sway recording is **45 seconds, 1100x700, video only**. It shows three
544 panes across two isolated daemon targets, close/reopen with one target unavailable,
545 that target returning, a shell exit and missing-session restore, explicit creation
546 in place, Detach survival, End, and continued input to the remaining pane. Three
547 continuous window recordings are joined across restarts, omitting the desktop
548 between owned windows. Script and assertions: `/tmp/muxg-sprint4-demo/record.py`
549 and `record.log`. Owned fixture processes were stopped after recording.
550
551 Retained video:
552 `/home/xanderle/.codex/visualizations/2026/09/04/01a06dd2-1bb6-79c3-8a66-bc6bd8f2dedf/native-tiling-sprint4.mp4`.
553 Private playback: `https://charizard.folk-amberjack.ts.net/sprint4/`, verified with
554 HTTPS 206 byte-range delivery. The loopback server is
555 `/tmp/muxg-sprint4-demo/server.py`, port 18768. Remove only this route with
556 `tailscale serve --https=443 --set-path /sprint4 off`, then stop the owned server;
557 earlier demo routes remain available. The recording does not claim a hands-on
558 trial or a live remote host outage; remote behavior is validated separately.
docs/superpowers/specs/2026-09-05-native-tiling-design.md
Old New
@@ -1,7 +1,8 @@
1 # Native tiling — draft design and delivery plan 1 # Native tiling — draft design and delivery plan
2 2
3 2026-09-05. Status: Sprint 3 resizing implemented, reviewed, validated, and accepted 3 2026-09-05. Status: Sprint 4 lifecycle and persistence implemented, reviewed, and
4 by the user after the mouse-focused recording. 4 validated; recorded-demo acceptance pending. Sprint 3 was accepted after its
5 mouse-focused recording.
5 6
6 Execution: [delegated work packages and review gates](../plans/2026-09-05-native-tiling.md). 7 Execution: [delegated work packages and review gates](../plans/2026-09-05-native-tiling.md).
7 8
@@ -178,7 +179,15 @@ existing handling of other attached clients. A failure to end keeps the pane and
178 shows the reason. Transport cancellation/GUI shutdown is not session termination: 179 shows the reason. Transport cancellation/GUI shutdown is not session termination:
179 the current session pump's `quit` and `detach` both stop the attachment. 180 the current session pump's `quit` and `detach` both stop the attachment.
180 181
181 Use a separate, versioned GUI state file, proposed at 182 Sprint 4 sends End on the pane's own attachment so the daemon excludes that
183 attachment from its other-client count. Refusal offers an explicit **End for all
184 clients** action with **Cancel** selected; no automatic force or retry of End.
185 A lost or timed-out reply reports an unknown outcome. Retry establishes a fresh
186 attachment before another End may be issued. Recovery and confirmation retain
187 their originating pane and attachment generation; confirmations identify the
188 session and target and cannot follow a later focus change to a different pane.
189
190 Use a separate, versioned GUI state file at
182 `$XDG_STATE_HOME/mux/native-workspace.json`. The initial schema has an ordered 191 `$XDG_STATE_HOME/mux/native-workspace.json`. The initial schema has an ordered
183 `tabs` collection and `active_tab_id`, even while only one tab is exposed. Persist 192 `tabs` collection and `active_tab_id`, even while only one tab is exposed. Persist
184 each tab's stable ID, split tree, relative weights, panes, and focus. Pane records 193 each tab's stable ID, split tree, relative weights, panes, and focus. Pane records
@@ -189,6 +198,15 @@ committed changes; preserve unreadable state rather than overwriting it with an
189 empty workspace. Save divider changes when a drag finishes. A saved host reference 198 empty workspace. Save divider changes when a drag finishes. A saved host reference
190 must survive removal from the shared host catalogue. 199 must survive removal from the shared host catalogue.
191 200
201 The version 1 document stores `active_tab_id`, `next_pane_id`, and one entry in
202 `tabs`. Each tab stores its ID, focus, owned pane targets/session names, and the
203 tree's root, nodes, relative weights, and next divider ID. Transient modes and
204 attachment state are excluded. Filesystem target references are made absolute
205 when saved. Unsupported versions/tabs, invalid identities, malformed trees, and
206 unreadable files disable writes for the run while allowing a fallback picker.
207 A sibling `.lock` file holds exclusive ownership across atomic JSON replacement.
208 State and temporary files use mode 0600; a newly created parent uses 0700.
209
192 No-argument `muxg` restores the GUI workspace; first launch presents the picker. 210 No-argument `muxg` restores the GUI workspace; first launch presents the picker.
193 An explicit CLI target opens a temporary workspace without replacing the default 211 An explicit CLI target opens a temporary workspace without replacing the default
194 saved workspace. For this milestone, allow one writer for the default workspace; 212 saved workspace. For this milestone, allow one writer for the default workspace;
src/cli/muxg.zig
Old New
@@ -5,6 +5,7 @@ const client = @import("client");
5 const term = @import("term"); 5 const term = @import("term");
6 const cliflags = @import("cliflags"); 6 const cliflags = @import("cliflags");
7 const sockpath = @import("sockpath"); 7 const sockpath = @import("sockpath");
8 const xdg = @import("xdg");
8 9
9 const proto = term.protocol; 10 const proto = term.protocol;
10 const hosts = client.hosts; 11 const hosts = client.hosts;
@@ -12,7 +13,7 @@ const hosts = client.hosts;
12 const usage = 13 const usage =
13 \\usage: muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N] 14 \\usage: muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N]
14 \\ 15 \\
15 \\ TARGET HOST (ssh handoff) or quic://HOST[:PORT]; none means the local daemon 16 \\ TARGET HOST (ssh handoff) or quic://HOST[:PORT]; none restores the saved workspace
16 \\ --session the session name (default: the daemon's default session) 17 \\ --session the session name (default: the daemon's default session)
17 \\ --sock a local daemon's socket path 18 \\ --sock a local daemon's socket path
18 \\ --via a command whose stdio is the daemon 19 \\ --via a command whose stdio is the daemon
@@ -66,17 +67,25 @@ pub fn main() !u8 {
66 const session = if (o.session) |n| n.name else ""; 67 const session = if (o.session) |n| n.name else "";
67 const key = std.posix.getenv("MUX_KEY_FILE"); 68 const key = std.posix.getenv("MUX_KEY_FILE");
68 69
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| { 70 const temporary = named != 0 or o.session != null;
71 const local_path: ?[]const u8 = if (o.sock) |path| path else if (o._target != null or o.via != null) null else if (temporary) (try sockpath.defaultOrExplain(argv_alloc, "muxg") orelse return 1) else sockpath.defaultSockPath(argv_alloc) catch null;
72 const local: ?client.Target = if (local_path) |path| .{ .sock = path } else null;
73 const target: ?client.Target = if (!temporary) null else if (o.via) |cmd| .{ .via = cmd } else if (o._target) |word| resolve(argv_alloc, word, o.key orelse key) catch |err| {
70 std.debug.print("muxg: bad target: {s}\n", .{@errorName(err)}); 74 std.debug.print("muxg: bad target: {s}\n", .{@errorName(err)});
71 return 2; 75 return 2;
72 } else .{ .sock = if (o.sock) |path| path else (try sockpath.defaultOrExplain(argv_alloc, "muxg") orelse return 1) }; 76 } else local;
73 return native.run(alloc, .{ 77 return native.run(alloc, .{
74 .target = target, 78 .target = target,
79 .local_target = local,
80 .state_path = if (temporary) null else try xdg.statePath(argv_alloc, "native-workspace.json"),
75 .key_path = o.key orelse key, 81 .key_path = o.key orelse key,
76 .session = session, 82 .session = session,
77 .font_px = o.font_px, 83 .font_px = o.font_px,
78 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"), 84 .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"),
79 }); 85 }) catch |err| {
86 std.debug.print("muxg: {s}\n", .{if (err == error.WorkspaceAlreadyOpen) "the saved workspace is already open" else @errorName(err)});
87 return 2;
88 };
80 } 89 }
81 fn resolve(alloc: std.mem.Allocator, word: []const u8, key: ?[]const u8) !client.Target { 90 fn resolve(alloc: std.mem.Allocator, word: []const u8, key: ?[]const u8) !client.Target {
82 var target = try client.Target.fromSpec(alloc, try hosts.parse(word), key, client.quic_idle_ms_default, false); 91 var target = try client.Target.fromSpec(alloc, try hosts.parse(word), key, client.quic_idle_ms_default, false);
src/client/session_pump.zig
Old New
@@ -8,12 +8,24 @@ const term = @import("term");
8 const proto = term.protocol; 8 const proto = term.protocol;
9 const Wire = @import("buffered_wire.zig").Wire; 9 const Wire = @import("buffered_wire.zig").Wire;
10 10
11 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, end: struct { request: u64, force: bool = false }, detach, quit };
12 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 };
13 pub const EndPhase = enum { idle, pending, accepted, refused, unknown };
14 pub const EndState = struct {
15 request: u64 = 0,
16 phase: EndPhase = .idle,
17 others: u8 = 0,
18 reason: [256]u8 = @splat(0),
19 reason_len: usize = 0,
20 pub fn reasonText(self: *const EndState) []const u8 {
21 return self.reason[0..self.reason_len];
22 }
23 };
13 pub const State = struct { 24 pub const State = struct {
14 phase: Phase = .dialing, 25 phase: Phase = .dialing,
15 exit_code: u8 = 0, 26 exit_code: u8 = 0,
16 bell: bool = false, 27 bell: bool = false,
28 ending: EndState = .{},
17 reason: [1024]u8 = @splat(0), 29 reason: [1024]u8 = @splat(0),
18 reason_len: usize = 0, 30 reason_len: usize = 0,
19 31
@@ -29,6 +41,9 @@ pub const Options = struct {
29 rows: u16, 41 rows: u16,
30 /// Join only: never create a vanished session, including on reconnect. 42 /// Join only: never create a vanished session, including on reconnect.
31 existing_only: bool = false, 43 existing_only: bool = false,
44 retry_initial: bool = false,
45 open_timeout_ms: u32 = 15000,
46 end_timeout_ms: u32 = 2000,
32 wake: ?*const fn (?*anyopaque) void = null, 47 wake: ?*const fn (?*anyopaque) void = null,
33 wake_ctx: ?*anyopaque = null, 48 wake_ctx: ?*anyopaque = null,
34 }; 49 };
@@ -41,6 +56,7 @@ pub const Pump = struct {
41 replica: term.replica.Replica, 56 replica: term.replica.Replica,
42 core: client.core.ClientCore = .{}, 57 core: client.core.ClientCore = .{},
43 last_apply_us: u32 = 0, 58 last_apply_us: u32 = 0,
59 snapshot_ready: bool = false, // mu: set only after a complete valid snapshot
44 status: State = .{}, 60 status: State = .{},
45 mailbox_mu: std.Thread.Mutex = .{}, 61 mailbox_mu: std.Thread.Mutex = .{},
46 mailbox: std.ArrayList(Say) = .empty, 62 mailbox: std.ArrayList(Say) = .empty,
@@ -49,6 +65,7 @@ pub const Pump = struct {
49 closing: std.atomic.Value(bool) = .init(false), 65 closing: std.atomic.Value(bool) = .init(false),
50 thread: ?std.Thread = null, 66 thread: ?std.Thread = null,
51 admitted: bool = false, 67 admitted: bool = false,
68 end_until: i64 = 0, // guarded by mu with status.ending
52 69
53 pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump { 70 pub fn start(alloc: std.mem.Allocator, opts: Options) !*Pump {
54 if (opts.session.len != 0 and !proto.validSessionName(opts.session)) return error.InvalidSession; 71 if (opts.session.len != 0 and !proto.validSessionName(opts.session)) return error.InvalidSession;
@@ -72,6 +89,16 @@ pub const Pump = struct {
72 defer self.mailbox_mu.unlock(); 89 defer self.mailbox_mu.unlock();
73 if (self.closing.load(.acquire)) return; 90 if (self.closing.load(.acquire)) return;
74 switch (msg) { 91 switch (msg) {
92 .end => |req| {
93 self.mu.lock();
94 defer self.mu.unlock();
95 if (self.status.ending.phase == .pending) return error.EndPending;
96 if (self.status.ending.phase == .unknown) return error.EndOutcomeUnknown;
97 if (self.status.phase != .attached) return error.NotAttached;
98 try self.mailbox.append(self.alloc, msg);
99 self.status.ending = .{ .request = req.request, .phase = .pending };
100 self.end_until = std.time.milliTimestamp() + self.opts.end_timeout_ms;
101 },
75 .quit, .detach => { 102 .quit, .detach => {
76 self.closing.store(true, .release); 103 self.closing.store(true, .release);
77 ring(self.cancel_pipe[1], client.keymap.detach_key); 104 ring(self.cancel_pipe[1], client.keymap.detach_key);
@@ -124,6 +151,7 @@ pub const Pump = struct {
124 } 151 }
125 152
126 fn entry(self: *Pump) void { 153 fn entry(self: *Pump) void {
154 defer self.finishPendingEnd("Connection ended before the End reply; outcome unknown");
127 self.run() catch |err| { 155 self.run() catch |err| {
128 self.publish(.failed, 1, @errorName(err)); 156 self.publish(.failed, 1, @errorName(err));
129 return; 157 return;
@@ -142,14 +170,16 @@ pub const Pump = struct {
142 var backoff: u64 = 0; 170 var backoff: u64 = 0;
143 while (!self.closing.load(.acquire)) { 171 while (!self.closing.load(.acquire)) {
144 var dial: client.handoff.Dial = .{}; 172 var dial: client.handoff.Dial = .{};
145 var tr = client.Transport.open(self.alloc, self.opts.target, null, self.cancel_pipe[0], &dial) catch |err| { 173 self.expireEnd();
174 var tr = client.Transport.openUntil(self.alloc, self.opts.target, null, self.cancel_pipe[0], &dial, std.time.milliTimestamp() + self.opts.open_timeout_ms) catch |err| {
146 if (self.closing.load(.acquire)) return; 175 if (self.closing.load(.acquire)) return;
147 if (first) { 176 var buf: [1024]u8 = undefined;
148 var buf: [1024]u8 = undefined; 177 const failure = client.openFailure(&buf, self.opts.target, err, dial.reason.slice());
149 const failure = client.openFailure(&buf, self.opts.target, err, dial.reason.slice()); 178 if (first and !(self.opts.retry_initial and retryableOpen(err))) {
150 self.publish(.dial_failed, 2, failure.msg); 179 self.publish(.dial_failed, 2, failure.msg);
151 return; 180 return;
152 } 181 }
182 self.publish(.reconnecting, 0, failure.msg);
153 backoff = client.nextBackoffMs(backoff); 183 backoff = client.nextBackoffMs(backoff);
154 try self.waitRetry(backoff); 184 try self.waitRetry(backoff);
155 continue; 185 continue;
@@ -160,6 +190,7 @@ pub const Pump = struct {
160 var wire = try Wire.init(self.alloc, &tr); 190 var wire = try Wire.init(self.alloc, &tr);
161 defer wire.deinit(); 191 defer wire.deinit();
162 const ended = self.connected(&wire) catch |err| try connectionFailure(err); 192 const ended = self.connected(&wire) catch |err| try connectionFailure(err);
193 self.finishPendingEnd("Connection lost before the End reply; outcome unknown");
163 if (ended or self.closing.load(.acquire)) return; 194 if (ended or self.closing.load(.acquire)) return;
164 tr.close(); 195 tr.close();
165 self.publish(.reconnecting, 0, "connection lost"); 196 self.publish(.reconnecting, 0, "connection lost");
@@ -194,6 +225,19 @@ pub const Pump = struct {
194 messages.deinit(self.alloc); 225 messages.deinit(self.alloc);
195 } 226 }
196 for (messages.items) |msg| switch (msg) { 227 for (messages.items) |msg| switch (msg) {
228 .end => |req| {
229 self.expireEnd();
230 self.mu.lock();
231 const pending = self.status.ending.request == req.request and self.status.ending.phase == .pending;
232 self.mu.unlock();
233 if (!pending) continue;
234 if (!self.admitted) {
235 self.finishPendingEnd("Attachment changed before End; retry to check the session");
236 continue;
237 }
238 var buf: [proto.end_req_max_len]u8 = undefined;
239 try wire.send(.end_req, proto.encodeEndReq(&buf, req.force, self.opts.session));
240 },
197 .input => |bytes| try wire.send(.input, bytes), 241 .input => |bytes| try wire.send(.input, bytes),
198 .resize => |size| { 242 .resize => |size| {
199 self.opts.cols = size.cols; 243 self.opts.cols = size.cols;
@@ -211,6 +255,7 @@ pub const Pump = struct {
211 try self.attach(wire, false); 255 try self.attach(wire, false);
212 var eager = wire.tr.link == .quic; 256 var eager = wire.tr.link == .quic;
213 while (true) { 257 while (true) {
258 self.expireEnd();
214 try self.mail(wire); 259 try self.mail(wire);
215 if (self.closing.load(.acquire)) { 260 if (self.closing.load(.acquire)) {
216 try wire.send(.detach, ""); 261 try wire.send(.detach, "");
@@ -231,7 +276,7 @@ pub const Pump = struct {
231 .{ .fd = wire.tr.errFd() orelse -1, .events = std.posix.POLL.IN, .revents = 0 }, 276 .{ .fd = wire.tr.errFd() orelse -1, .events = std.posix.POLL.IN, .revents = 0 },
232 .{ .fd = if (wire.tr.link != .quic and wire.pending()) wire.writeFd() else -1, .events = std.posix.POLL.OUT, .revents = 0 }, 277 .{ .fd = if (wire.tr.link != .quic and wire.pending()) wire.writeFd() else -1, .events = std.posix.POLL.OUT, .revents = 0 },
233 }; 278 };
234 _ = try std.posix.poll(&fds, wire.tr.timeoutMs(if (eager) 0 else 1000)); 279 _ = try std.posix.poll(&fds, self.endWaitMs(wire.tr.timeoutMs(if (eager) 0 else 1000)));
235 wire.tr.service(); 280 wire.tr.service();
236 if (fds[2].revents != 0) wire.tr.drainErr(); 281 if (fds[2].revents != 0) wire.tr.drainErr();
237 if (fds[3].revents != 0) try wire.flush(); 282 if (fds[3].revents != 0) try wire.flush();
@@ -274,6 +319,13 @@ pub const Pump = struct {
274 self.mu.lock(); 319 self.mu.lock();
275 defer self.mu.unlock(); 320 defer self.mu.unlock();
276 switch (kind) { 321 switch (kind) {
322 .end_reply => {
323 if (self.status.ending.phase != .pending) return .skip;
324 if (proto.parseEndReply(payload)) |reply| {
325 self.setEnd(if (reply.accepted) .accepted else .refused, reply.others, reply.reason);
326 } else self.setEnd(.unknown, 0, "Invalid End reply; retry to check the session");
327 return .changed;
328 },
277 .snapshot, .delta => { 329 .snapshot, .delta => {
278 const begin = std.time.nanoTimestamp(); 330 const begin = std.time.nanoTimestamp();
279 const applied = self.replica.apply(kind, payload) catch |err| switch (err) { 331 const applied = self.replica.apply(kind, payload) catch |err| switch (err) {
@@ -282,6 +334,7 @@ pub const Pump = struct {
282 }; 334 };
283 self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000)))); 335 self.last_apply_us = @intCast(@min(std.math.maxInt(u32), @max(0, @divTrunc(std.time.nanoTimestamp() - begin, 1000))));
284 if (applied == .resync) return .resync; 336 if (applied == .resync) return .resync;
337 if (kind == .snapshot) self.snapshot_ready = true;
285 self.setState(.attached, 0, ""); 338 self.setState(.attached, 0, "");
286 return .changed; 339 return .changed;
287 }, 340 },
@@ -305,8 +358,41 @@ pub const Pump = struct {
305 } 358 }
306 return .changed; 359 return .changed;
307 } 360 }
361 fn setEnd(self: *Pump, phase: EndPhase, others: u8, reason: []const u8) void {
362 self.status.ending.phase = phase;
363 self.status.ending.others = others;
364 self.status.ending.reason_len = @min(reason.len, self.status.ending.reason.len);
365 @memcpy(self.status.ending.reason[0..self.status.ending.reason_len], reason[0..self.status.ending.reason_len]);
366 }
367 fn expireEnd(self: *Pump) void {
368 self.mu.lock();
369 const expired = self.status.ending.phase == .pending and std.time.milliTimestamp() >= self.end_until;
370 if (expired) self.setEnd(.unknown, 0, "End reply timed out; outcome unknown. Retry to check the session");
371 self.mu.unlock();
372 if (expired) self.wake();
373 }
374 fn endWaitMs(self: *Pump, cap: i32) i32 {
375 self.mu.lock();
376 defer self.mu.unlock();
377 if (self.status.ending.phase != .pending) return cap;
378 return @intCast(@min(cap, @max(0, self.end_until - std.time.milliTimestamp())));
379 }
380 fn finishPendingEnd(self: *Pump, reason: []const u8) void {
381 self.mu.lock();
382 const pending = self.status.ending.phase == .pending;
383 if (pending) self.setEnd(.unknown, 0, reason);
384 self.mu.unlock();
385 if (pending) self.wake();
386 }
308 }; 387 };
309 388
389 fn retryableOpen(err: anyerror) bool {
390 return switch (err) {
391 error.FileNotFound, error.ConnectionRefused, error.ConnectionTimedOut, error.Timeout, error.NetworkUnreachable, error.HostUnreachable, error.QuicHandshakeFailed, error.UnknownHostName, error.UnterminatedLine => true,
392 else => false,
393 };
394 }
395
310 // Only a lost connection earns a redial. Resource exhaustion, poll errors, 396 // Only a lost connection earns a redial. Resource exhaustion, poll errors,
311 // and other local failures must reach entry's failure publication. 397 // and other local failures must reach entry's failure publication.
312 fn connectionFailure(err: anyerror) anyerror!bool { 398 fn connectionFailure(err: anyerror) anyerror!bool {
@@ -737,3 +823,97 @@ test "existing-only pump never sends a create-size attach or pre-admission resiz
737 var bytes: [8]u8 = undefined; 823 var bytes: [8]u8 = undefined;
738 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(stream.handle, &bytes)); 824 try std.testing.expectEqual(@as(usize, 0), try std.posix.read(stream.handle, &bytes));
739 } 825 }
826
827 fn testEnd(pump: *Pump, phase: EndPhase) !EndState {
828 const until = std.time.milliTimestamp() + 2500;
829 while (std.time.milliTimestamp() < until) {
830 const result = pump.state().ending;
831 if (result.phase == phase) return result;
832 std.Thread.sleep(std.time.ns_per_ms);
833 }
834 return error.EndResultTimeout;
835 }
836
837 test "pump End uses attached link refuses shared sessions and forces only a second explicit request" {
838 var peer = try TestPeer.init();
839 defer peer.deinit();
840 const pump = try peer.start();
841 defer pump.stop();
842 const stream = try peer.accept();
843 defer stream.close();
844 const attach_frame = try testFrame(stream, .attach);
845 defer attach_frame.deinit(std.testing.allocator);
846 try std.testing.expectError(error.NotAttached, pump.say(.{ .end = .{ .request = 1 } }));
847 try proto.writeFrame(stream.handle, .snapshot, &testSnapshot());
848 _ = try testPhase(pump, .attached);
849 try pump.say(.{ .end = .{ .request = 2 } });
850 const request = try testFrame(stream, .end_req);
851 defer request.deinit(std.testing.allocator);
852 try std.testing.expectEqual(@as(u8, 0), request.payload[0]);
853 try std.testing.expectEqualStrings("native-test", request.payload[1..]);
854 try std.testing.expectError(error.EndPending, pump.say(.{ .end = .{ .request = 3, .force = true } }));
855 var buf: [proto.end_reply_max_len]u8 = undefined;
856 try proto.writeFrame(stream.handle, .end_reply, proto.encodeEndReply(&buf, false, 1, proto.end_reason.others_attached));
857 const refusal = try testEnd(pump, .refused);
858 try std.testing.expectEqual(@as(u64, 2), refusal.request);
859 try std.testing.expectEqual(@as(u8, 1), refusal.others);
860 try std.testing.expectEqual(Phase.attached, pump.state().phase);
861 try pump.say(.{ .end = .{ .request = 4, .force = true } });
862 const force = try testFrame(stream, .end_req);
863 defer force.deinit(std.testing.allocator);
864 try std.testing.expectEqual(@as(u8, 1), force.payload[0]);
865 try proto.writeFrame(stream.handle, .end_reply, proto.encodeEndReply(&buf, true, 1, "ended"));
866 _ = try testEnd(pump, .accepted);
867 try proto.writeFrame(stream.handle, .exit_status, &.{0});
868 _ = try testPhase(pump, .exited);
869 try std.testing.expectEqual(EndPhase.accepted, pump.state().ending.phase);
870 }
871
872 test "unacknowledged End times out without retry and late replies cannot authorize another End" {
873 var peer = try TestPeer.init();
874 defer peer.deinit();
875 const pump = try Pump.start(std.testing.allocator, .{ .target = .{ .sock = peer.path }, .session = "native-test", .cols = 11, .rows = 3, .end_timeout_ms = 30 });
876 defer pump.stop();
877 const stream = try peer.accept();
878 defer stream.close();
879 const attach_frame = try testFrame(stream, .attach);
880 defer attach_frame.deinit(std.testing.allocator);
881 try proto.writeFrame(stream.handle, .snapshot, &testSnapshot());
882 _ = try testPhase(pump, .attached);
883 const started = std.time.milliTimestamp();
884 try pump.say(.{ .end = .{ .request = 1 } });
885 const request = try testFrame(stream, .end_req);
886 defer request.deinit(std.testing.allocator);
887 _ = try testEnd(pump, .unknown);
888 try std.testing.expect(std.time.milliTimestamp() - started < 500);
889 var buf: [proto.end_reply_max_len]u8 = undefined;
890 try proto.writeFrame(stream.handle, .end_reply, proto.encodeEndReply(&buf, false, 1, "late refusal"));
891 try pump.say(.{ .input = "still usable" });
892 const input = try testFrame(stream, .input);
893 defer input.deinit(std.testing.allocator);
894 try std.testing.expectError(error.EndOutcomeUnknown, pump.say(.{ .end = .{ .request = 2 } }));
895 try proto.writeFrame(stream.handle, .exit_status, &.{7});
896 _ = try testPhase(pump, .exited);
897 try std.testing.expectEqual(EndPhase.unknown, pump.state().ending.phase);
898 }
899
900 test "restored SSH retries an initial announce EOF and joins when the host answers" {
901 const a = std.testing.allocator;
902 var tmp = std.testing.tmpDir(.{});
903 defer tmp.cleanup();
904 const path = try tmp.dir.realpathAlloc(a, ".");
905 defer a.free(path);
906 var bytes: std.ArrayList(u8) = .empty;
907 defer bytes.deinit(a);
908 try proto.appendFrame(&bytes, a, .snapshot, &testSnapshot());
909 try tmp.dir.writeFile(.{ .sub_path = "snapshot", .data = bytes.items });
910 const script = try std.fmt.allocPrint(a, "if test ! -e {s}/once; then touch {s}/once; printf 'connection refused\\n' >&2; exit 1; fi; printf 'endpoint none\\n'; cat {s}/snapshot -", .{ path, path, path });
911 defer a.free(script);
912 const pump = try Pump.start(a, .{ .target = .{ .hand = .{ .host = "fixture", .ssh_argv = &.{ "/bin/sh", "-c", script }, .cache_path = null, .deadline_ms = 200 } }, .session = "native-test", .cols = 11, .rows = 3, .existing_only = true, .retry_initial = true });
913 defer pump.stop();
914 _ = try testPhase(pump, .attached);
915 pump.mu.lock();
916 defer pump.mu.unlock();
917 try std.testing.expect(pump.snapshot_ready);
918 try std.testing.expectEqual(@as(u64, 37), pump.replica.last_seq);
919 }
src/gui/frame.zig
Old New
@@ -6,6 +6,7 @@ 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 picker_mod = @import("picker.zig");
9 const persistence = @import("persistence.zig");
9 const font = @import("font.zig"); 10 const font = @import("font.zig");
10 const atlas = @import("atlas.zig"); 11 const atlas = @import("atlas.zig");
11 const quads = @import("quads.zig"); 12 const quads = @import("quads.zig");
@@ -17,9 +18,11 @@ const c = @cImport({
17 }); 18 });
18 19
19 pub const Options = struct { 20 pub const Options = struct {
20 target: client.Target, 21 target: ?client.Target = null,
22 local_target: ?client.Target = null,
23 state_path: ?[]const u8 = null,
21 key_path: ?[]const u8 = null, 24 key_path: ?[]const u8 = null,
22 session: []const u8, 25 session: []const u8 = "0",
23 /// Face pixel size at 100% display scale. 26 /// Face pixel size at 100% display scale.
24 font_px: u16 = 16, 27 font_px: u16 = 16,
25 width: u32 = 960, 28 width: u32 = 960,
@@ -105,7 +108,7 @@ pub fn parseHook(line: []const u8) ?Hook {
105 if (std.mem.startsWith(u8, line, "key:")) { 108 if (std.mem.startsWith(u8, line, "key:")) {
106 const name = line["key:".len..]; 109 const name = line["key:".len..];
107 if (std.mem.eql(u8, name, "prefix")) return .{ .key = .{ .code = c.SDLK_BACKSLASH, .mods = c.SDL_KMOD_CTRL } }; 110 if (std.mem.eql(u8, name, "prefix")) return .{ .key = .{ .code = c.SDLK_BACKSLASH, .mods = c.SDL_KMOD_CTRL } };
108 if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvbr", name[0]) != null) return .{ .key = .{ .code = name[0] } }; 111 if (name.len == 1 and std.mem.indexOfScalar(u8, "hjklvbrdxp", name[0]) != null) return .{ .key = .{ .code = name[0] } };
109 inline for (.{ .{ "enter", c.SDLK_RETURN }, .{ "tab", c.SDLK_TAB }, .{ "escape", c.SDLK_ESCAPE }, .{ "backspace", c.SDLK_BACKSPACE }, .{ "up", c.SDLK_UP }, .{ "down", c.SDLK_DOWN }, .{ "left", c.SDLK_LEFT }, .{ "right", c.SDLK_RIGHT } }) |pair| { 112 inline for (.{ .{ "enter", c.SDLK_RETURN }, .{ "tab", c.SDLK_TAB }, .{ "escape", c.SDLK_ESCAPE }, .{ "backspace", c.SDLK_BACKSPACE }, .{ "up", c.SDLK_UP }, .{ "down", c.SDLK_DOWN }, .{ "left", c.SDLK_LEFT }, .{ "right", c.SDLK_RIGHT } }) |pair| {
110 if (std.mem.eql(u8, name, pair[0])) return .{ .key = .{ .code = pair[1] } }; 113 if (std.mem.eql(u8, name, pair[0])) return .{ .key = .{ .code = pair[1] } };
111 } 114 }
@@ -291,6 +294,43 @@ fn sdlFail(op: []const u8) u8 {
291 294
292 /// This state belongs to the window thread. Geometry and input select stable 295 /// This state belongs to the window thread. Geometry and input select stable
293 /// pane IDs; transport ownership lives in Runtime and rendering uses snapshots. 296 /// pane IDs; transport ownership lives in Runtime and rendering uses snapshots.
297 const Recovery = struct {
298 kind: enum { recovery, ending, force_end },
299 key: model.Attachment,
300 selected: usize = 0,
301 notice: [1024]u8 = @splat(0),
302 notice_len: usize = 0,
303 fn count(self: Recovery) usize {
304 return switch (self.kind) {
305 .recovery => 3,
306 .ending => 1,
307 .force_end => 2,
308 };
309 }
310 fn label(self: Recovery, index: usize) []const u8 {
311 return switch (self.kind) {
312 .recovery => ([_][]const u8{ "Retry", "Choose session", "Detach" })[index],
313 .ending => "Dismiss (End is already requested)",
314 .force_end => ([_][]const u8{ "Cancel", "End for all clients" })[index],
315 };
316 }
317 fn title(self: Recovery) []const u8 {
318 return switch (self.kind) {
319 .recovery => "Pane actions",
320 .ending => "Ending session...",
321 .force_end => "Other clients are attached",
322 };
323 }
324 fn setNotice(self: *Recovery, text: []const u8) void {
325 self.notice_len = @min(text.len, self.notice.len);
326 @memcpy(self.notice[0..self.notice_len], text[0..self.notice_len]);
327 }
328 fn view(self: Recovery, width: u32, height: u32, metrics: model.Metrics) picker_mod.View {
329 const w = @min(width, @as(u32, metrics.cell_w) * 74);
330 const h = @min(height, @as(u32, metrics.cell_h) * @as(u32, @intCast(self.count() + 4)));
331 return .{ .rect = .{ .x = (width - w) / 2, .y = (height - h) / 2, .w = w, .h = h }, .first = 0, .shown = self.count(), .row_height = metrics.cell_h };
332 }
333 };
294 const Events = struct { 334 const Events = struct {
295 rt: *runtime.Runtime, 335 rt: *runtime.Runtime,
296 win: *c.SDL_Window, 336 win: *c.SDL_Window,
@@ -301,6 +341,14 @@ const Events = struct {
301 picker: ?*picker_mod.Picker = null, 341 picker: ?*picker_mod.Picker = null,
302 next_request: u64 = 1, 342 next_request: u64 = 1,
303 key_path: ?[]const u8 = null, 343 key_path: ?[]const u8 = null,
344 local_target: ?client.Target = null,
345 store: ?*persistence.Store = null,
346 intent_dirty: bool = false,
347 save_notice: [256]u8 = @splat(0),
348 save_notice_len: usize = 0,
349 recovery: ?Recovery = null,
350 pending_end: ?struct { key: model.Attachment, request: u64 } = null,
351 notice_owned: [1024]u8 = @splat(0),
304 layout: model.Layout = .{}, 352 layout: model.Layout = .{},
305 metrics: model.Metrics, 353 metrics: model.Metrics,
306 fb_w: c_int, 354 fb_w: c_int,
@@ -310,16 +358,17 @@ const Events = struct {
310 suppress_text: bool = false, 358 suppress_text: bool = false,
311 command_mode: bool = false, 359 command_mode: bool = false,
312 resize_mode: bool = false, 360 resize_mode: bool = false,
313 resize_held: std.AutoHashMapUnmanaged(u32, void) = .empty, 361 modal_held: std.AutoHashMapUnmanaged(u32, void) = .empty,
314 logical_w: c_int = 0, 362 logical_w: c_int = 0,
315 logical_h: c_int = 0, 363 logical_h: c_int = 0,
316 drag: ?struct { id: model.DividerId, tab: model.TabId, offset: i64 } = null, 364 drag: ?struct { id: model.DividerId, tab: model.TabId, offset: i64, changed: bool = false } = null,
317 consumed_key: ?u32 = null, 365 consumed_key: ?u32 = null,
318 notice: []const u8 = "", 366 notice: []const u8 = "",
319 367
320 fn deinit(self: *Events) void { 368 fn deinit(self: *Events) void {
369 if (self.pending_end != null) std.debug.print("muxg: End was still pending; its remote outcome is unknown\n", .{});
321 self.cancelDrag(); 370 self.cancelDrag();
322 self.resize_held.deinit(self.rt.alloc); 371 self.modal_held.deinit(self.rt.alloc);
323 if (self.picker) |picker| picker.deinit(); 372 if (self.picker) |picker| picker.deinit();
324 } 373 }
325 fn command(self: *Events, key: u32) !void { 374 fn command(self: *Events, key: u32) !void {
@@ -328,20 +377,28 @@ const Events = struct {
328 self.notice = ""; 377 self.notice = "";
329 const ws = &self.rt.workspace; 378 const ws = &self.rt.workspace;
330 if (keyDirection(key)) |direction| { 379 if (keyDirection(key)) |direction| {
380 const before = ws.tab().focus;
331 ws.moveFocus(&self.layout, direction); 381 ws.moveFocus(&self.layout, direction);
382 self.intent_dirty = self.intent_dirty or before != ws.tab().focus;
332 } else switch (key) { 383 } else switch (key) {
333 c.SDLK_V => ws.arm(.beside), 384 c.SDLK_V => ws.arm(.beside),
334 c.SDLK_B => ws.arm(.stacked), 385 c.SDLK_B => ws.arm(.stacked),
335 c.SDLK_R => self.resize_mode = true, 386 c.SDLK_R => self.resize_mode = true,
387 c.SDLK_D => if (ws.tab().focus) |id| try self.detach(id),
388 c.SDLK_X => if (ws.tab().focus) |id| try self.beginEnd(self.rt.get(id).?.key, false),
389 c.SDLK_P => if (ws.tab().focus) |id| {
390 self.recovery = .{ .kind = .recovery, .key = self.rt.get(id).?.key };
391 self.recovery.?.setNotice(self.rt.get(id).?.status.reasonText());
392 },
336 c.SDLK_ESCAPE => ws.cancel(), 393 c.SDLK_ESCAPE => ws.cancel(),
337 c.SDLK_RETURN, c.SDLK_KP_ENTER => { 394 c.SDLK_RETURN, c.SDLK_KP_ENTER => {
338 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| { 395 self.openPicker(.insert) catch |err| {
339 self.notice = @errorName(err); 396 self.notice = @errorName(err);
340 self.dirty = true; 397 self.dirty = true;
341 return; 398 return;
342 }; 399 };
343 }, 400 },
344 else => self.notice = "v/b split, h/j/k/l focus, r resize, Enter picks session, Esc cancel", 401 else => self.notice = "v/b split, hjkl focus, r resize, d detach, x end, p actions, Enter add",
345 } 402 }
346 self.dirty = true; 403 self.dirty = true;
347 } 404 }
@@ -356,6 +413,7 @@ const Events = struct {
356 return self.handle(ev); 413 return self.handle(ev);
357 } 414 }
358 fn handle(self: *Events, ev: c.SDL_Event) !bool { 415 fn handle(self: *Events, ev: c.SDL_Event) !bool {
416 std.debug.assert(self.picker == null or self.recovery == null);
359 switch (ev.type) { 417 switch (ev.type) {
360 c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return false, 418 c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return false,
361 c.SDL_EVENT_TEXT_INPUT => { 419 c.SDL_EVENT_TEXT_INPUT => {
@@ -364,14 +422,14 @@ const Events = struct {
364 if (self.picker) |picker| { 422 if (self.picker) |picker| {
365 try picker.text(std.mem.span(ev.text.text)); 423 try picker.text(std.mem.span(ev.text.text));
366 self.dirty = true; 424 self.dirty = true;
367 } else if (!self.command_mode and !self.resize_mode) try self.rt.input(std.mem.span(ev.text.text)); 425 } else if (!self.command_mode and !self.resize_mode and self.recovery == null) try self.rt.input(std.mem.span(ev.text.text));
368 } 426 }
369 self.suppress_text = false; 427 self.suppress_text = false;
370 }, 428 },
371 c.SDL_EVENT_KEY_DOWN => { 429 c.SDL_EVENT_KEY_DOWN => {
372 self.suppress_text = false; 430 self.suppress_text = false;
373 const key = ev.key.key; 431 const key = ev.key.key;
374 if (!self.resize_mode and self.resize_held.contains(key)) { 432 if (!self.resize_mode and self.recovery == null and self.picker == null and self.modal_held.contains(key)) {
375 self.suppress_text = true; 433 self.suppress_text = true;
376 return true; 434 return true;
377 } 435 }
@@ -379,9 +437,28 @@ const Events = struct {
379 self.suppress_text = true; 437 self.suppress_text = true;
380 return true; 438 return true;
381 } 439 }
440 if (self.recovery) |*menu| {
441 try self.modal_held.put(self.rt.alloc, key, {});
442 self.suppress_text = true;
443 switch (key) {
444 c.SDLK_UP, c.SDLK_K => menu.selected -|= 1,
445 c.SDLK_DOWN, c.SDLK_J => menu.selected = @min(menu.selected + 1, menu.count() - 1),
446 c.SDLK_ESCAPE => {
447 self.consumed_key = key;
448 self.recovery = null;
449 },
450 c.SDLK_RETURN, c.SDLK_KP_ENTER => {
451 self.consumed_key = key;
452 try self.recoveryAction();
453 },
454 else => {},
455 }
456 self.dirty = true;
457 return true;
458 }
382 if (self.resize_mode) { 459 if (self.resize_mode) {
383 self.cancelDrag(); 460 self.cancelDrag();
384 try self.resize_held.put(self.rt.alloc, key, {}); 461 try self.modal_held.put(self.rt.alloc, key, {});
385 self.suppress_text = true; 462 self.suppress_text = true;
386 if (key == c.SDLK_ESCAPE or key == c.SDLK_RETURN or key == c.SDLK_KP_ENTER) { 463 if (key == c.SDLK_ESCAPE or key == c.SDLK_RETURN or key == c.SDLK_KP_ENTER) {
387 self.resize_mode = false; 464 self.resize_mode = false;
@@ -392,13 +469,17 @@ const Events = struct {
392 self.notice = "Window too small to resize"; 469 self.notice = "Window too small to resize";
393 } else { 470 } else {
394 self.notice = ""; 471 self.notice = "";
395 if (self.rt.workspace.resizeFocused(direction, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics)) try self.relayout(); 472 if (self.rt.workspace.resizeFocused(direction, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics)) {
473 try self.relayout();
474 self.intent_dirty = true;
475 }
396 } 476 }
397 } 477 }
398 self.dirty = true; 478 self.dirty = true;
399 return true; 479 return true;
400 } 480 }
401 if (self.picker) |picker| { 481 if (self.picker) |picker| {
482 try self.modal_held.put(self.rt.alloc, key, {});
402 const mapped: ?picker_mod.Key = switch (key) { 483 const mapped: ?picker_mod.Key = switch (key) {
403 c.SDLK_UP => .up, 484 c.SDLK_UP => .up,
404 c.SDLK_DOWN => .down, 485 c.SDLK_DOWN => .down,
@@ -446,7 +527,7 @@ const Events = struct {
446 } 527 }
447 }, 528 },
448 c.SDL_EVENT_KEY_UP => { 529 c.SDL_EVENT_KEY_UP => {
449 _ = self.resize_held.remove(ev.key.key); 530 _ = self.modal_held.remove(ev.key.key);
450 if (self.consumed_key == ev.key.key) self.consumed_key = null; 531 if (self.consumed_key == ev.key.key) self.consumed_key = null;
451 self.suppress_text = false; 532 self.suppress_text = false;
452 }, 533 },
@@ -454,7 +535,7 @@ const Events = struct {
454 self.suppress_text = false; 535 self.suppress_text = false;
455 self.command_mode = false; 536 self.command_mode = false;
456 self.resize_mode = false; 537 self.resize_mode = false;
457 self.resize_held.clearRetainingCapacity(); 538 self.modal_held.clearRetainingCapacity();
458 self.cancelDrag(); 539 self.cancelDrag();
459 self.consumed_key = null; 540 self.consumed_key = null;
460 self.dirty = true; 541 self.dirty = true;
@@ -495,13 +576,139 @@ const Events = struct {
495 const inserted = picker.inserted; 576 const inserted = picker.inserted;
496 picker.deinit(); 577 picker.deinit();
497 self.picker = null; 578 self.picker = null;
498 if (inserted) try self.relayout(); 579 if (inserted) {
580 try self.relayout();
581 self.intent_dirty = true;
582 }
583 self.dirty = true;
584 };
585 }
586 fn openPicker(self: *Events, mode: picker_mod.Picker.Mode) !void {
587 self.cancelDrag();
588 self.recovery = null;
589 self.notice = "";
590 const picker = try picker_mod.Picker.initMode(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, mode);
591 errdefer picker.deinit();
592 if (picker.origin == null) if (self.local_target) |target| try picker.includeTarget(target);
593 self.picker = picker;
594 self.dirty = true;
595 }
596 fn setNotice(self: *Events, text: []const u8) void {
597 const n = @min(text.len, self.notice_owned.len);
598 @memcpy(self.notice_owned[0..n], text[0..n]);
599 self.notice = self.notice_owned[0..n];
600 self.dirty = true;
601 }
602 fn detach(self: *Events, id: model.PaneId) !void {
603 self.cancelDrag();
604 self.resize_mode = false;
605 if (self.pending_end) |pending| if (pending.key.pane == id) {
606 self.pending_end = null;
607 self.setNotice("Pane detached; the pending End outcome is unknown");
608 };
609 self.recovery = null;
610 self.rt.remove(id);
611 try self.relayout();
612 self.intent_dirty = true;
613 }
614 fn beginEnd(self: *Events, key: model.Attachment, force: bool) !void {
615 if (!self.rt.accepts(key)) return;
616 if (self.pending_end != null) {
617 self.setNotice("An End request is still pending");
618 return;
619 }
620 const request = self.next_request;
621 self.next_request += 1;
622 self.rt.get(key.pane).?.pump.say(.{ .end = .{ .request = request, .force = force } }) catch |err| {
623 self.setNotice(@errorName(err));
624 self.recovery = null;
625 return;
626 };
627 self.pending_end = .{ .key = key, .request = request };
628 self.recovery = .{ .kind = .ending, .key = key };
629 self.dirty = true;
630 }
631 fn pollEnd(self: *Events) !void {
632 const pending = self.pending_end orelse return;
633 if (!self.rt.accepts(pending.key)) {
634 self.pending_end = null;
635 self.setNotice("Pane changed; the earlier End outcome is unknown");
636 return;
637 }
638 const result = self.rt.get(pending.key.pane).?.status.ending;
639 if (result.request != pending.request or result.phase == .pending) return;
640 self.pending_end = null;
641 const own_menu = if (self.recovery) |menu| menu.kind == .ending and std.meta.eql(menu.key, pending.key) else false;
642 if (own_menu) self.recovery = null;
643 switch (result.phase) {
644 .accepted => try self.detach(pending.key.pane),
645 .refused => {
646 self.setNotice(result.reasonText());
647 if (result.others > 0 and self.rt.workspace.tab().focus == pending.key.pane and self.picker == null and self.recovery == null and !self.resize_mode and self.drag == null) {
648 self.recovery = .{ .kind = .force_end, .key = pending.key };
649 self.recovery.?.setNotice(result.reasonText());
650 }
651 },
652 .unknown => self.setNotice(result.reasonText()),
653 else => {},
654 }
655 self.dirty = true;
656 }
657 fn recoveryAction(self: *Events) !void {
658 const menu = self.recovery orelse return;
659 self.recovery = null;
660 self.dirty = true;
661 if (!self.rt.accepts(menu.key)) {
662 self.setNotice("The pane changed");
663 return;
664 }
665 switch (menu.kind) {
666 .ending => self.setNotice("End requested; waiting for its outcome"),
667 .force_end => if (menu.selected == 1) try self.beginEnd(menu.key, true),
668 .recovery => switch (menu.selected) {
669 0, 1 => {
670 if (self.pending_end != null) {
671 self.setNotice("Wait for the pending End outcome before replacing the attachment");
672 return;
673 }
674 if (menu.selected == 0) {
675 self.rt.retry(menu.key.pane, self.layout.get(menu.key.pane).?) catch |err| {
676 self.setNotice(@errorName(err));
677 return;
678 };
679 self.intent_dirty = true;
680 } else {
681 _ = self.rt.workspace.focus(menu.key.pane);
682 try self.openPicker(.replace);
683 }
684 },
685 2 => try self.detach(menu.key.pane),
686 else => unreachable,
687 },
688 }
689 }
690 fn saveIntent(self: *Events) void {
691 if (!self.intent_dirty or self.drag != null) return;
692 self.intent_dirty = false;
693 const store = self.store orelse return;
694 store.save(&self.rt.workspace) catch |err| {
695 const text = std.fmt.bufPrint(&self.save_notice, "Workspace not saved: {s}", .{@errorName(err)}) catch unreachable;
696 self.save_notice_len = text.len;
499 self.dirty = true; 697 self.dirty = true;
698 return;
500 }; 699 };
700 self.save_notice_len = 0;
501 } 701 }
502 fn pointerDown(self: *Events, x: u32, y: u32, grab_x: u32, grab_y: u32) !void { 702 fn pointerDown(self: *Events, x: u32, y: u32, grab_x: u32, grab_y: u32) !void {
503 self.cancelDrag(); 703 self.cancelDrag();
504 if (self.picker) |picker| { 704 if (self.recovery) |menu| {
705 const view = menu.view(@intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics);
706 for (0..menu.count()) |i| if (view.rowRect(i).contains(x, y)) {
707 self.recovery.?.selected = i;
708 try self.recoveryAction();
709 break;
710 };
711 } else if (self.picker) |picker| {
505 try picker.click(x, y); 712 try picker.click(x, y);
506 try self.finishPicker(); 713 try self.finishPicker();
507 } else if (self.layout.hitDivider(x, y, grab_x, grab_y)) |id| { 714 } else if (self.layout.hitDivider(x, y, grab_x, grab_y)) |id| {
@@ -509,7 +716,10 @@ const Events = struct {
509 const position = if (d.direction == .beside) x else y; 716 const position = if (d.direction == .beside) x else y;
510 self.drag = .{ .id = id, .tab = self.rt.workspace.active_tab_id, .offset = @as(i64, position) - d.position() }; 717 self.drag = .{ .id = id, .tab = self.rt.workspace.active_tab_id, .offset = @as(i64, position) - d.position() };
511 } else if (!self.resize_mode) { 718 } else if (!self.resize_mode) {
512 if (self.layout.hit(x, y)) |id| _ = self.rt.workspace.focus(id); 719 if (self.layout.hit(x, y)) |id| {
720 self.intent_dirty = self.intent_dirty or self.rt.workspace.tab().focus != id;
721 _ = self.rt.workspace.focus(id);
722 } else if (self.layout.len == 0) try self.openPicker(.insert);
513 } 723 }
514 self.dirty = true; 724 self.dirty = true;
515 } 725 }
@@ -518,10 +728,14 @@ const Events = struct {
518 if (drag.tab != self.rt.workspace.active_tab_id) return self.cancelDrag(); 728 if (drag.tab != self.rt.workspace.active_tab_id) return self.cancelDrag();
519 const d = self.layout.divider(drag.id) orelse return self.cancelDrag(); 729 const d = self.layout.divider(drag.id) orelse return self.cancelDrag();
520 const position = (if (d.direction == .beside) x else y) - drag.offset; 730 const position = (if (d.direction == .beside) x else y) - drag.offset;
521 if (self.rt.workspace.resizeDivider(d.id, position, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics)) try self.relayout(); 731 if (self.rt.workspace.resizeDivider(d.id, position, @intCast(@max(self.fb_w, 0)), @intCast(@max(self.fb_h, 0)), self.metrics)) {
732 self.drag.?.changed = true;
733 try self.relayout();
734 }
522 } 735 }
523 fn cancelDrag(self: *Events) void { 736 fn cancelDrag(self: *Events) void {
524 if (self.drag == null) return; 737 if (self.drag == null) return;
738 self.intent_dirty = self.intent_dirty or self.drag.?.changed;
525 self.drag = null; 739 self.drag = null;
526 _ = c.SDL_CaptureMouse(false); 740 _ = c.SDL_CaptureMouse(false);
527 } 741 }
@@ -593,6 +807,16 @@ fn keyDirection(key: u32) ?model.Neighbor {
593 } 807 }
594 808
595 pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 { 809 pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
810 var store: ?persistence.Store = if (opts.state_path) |path| try persistence.Store.open(alloc, path) else null;
811 defer if (store) |*s| s.deinit();
812 var load_notice: [256]u8 = @splat(0);
813 var load_notice_len: usize = 0;
814 var saved: ?model.Workspace = if (store) |*s| s.load() catch |err| blk: {
815 const text = try std.fmt.bufPrint(&load_notice, "Workspace preserved; saving disabled: {s}", .{@errorName(err)});
816 load_notice_len = text.len;
817 break :blk null;
818 } else null;
819 defer if (saved) |*workspace| workspace.deinit();
596 var ring: bench.Ring = .{}; 820 var ring: bench.Ring = .{};
597 defer report(&ring); 821 defer report(&ring);
598 usr1_seen.store(false, .release); 822 usr1_seen.store(false, .release);
@@ -645,12 +869,22 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
645 if (wake.event_type == 0) return sdlFail("SDL_RegisterEvents"); 869 if (wake.event_type == 0) return sdlFail("SDL_RegisterEvents");
646 var rt = runtime.Runtime.init(alloc, .{ .ctx = &wake, .call = Wake.ring }); 870 var rt = runtime.Runtime.init(alloc, .{ .ctx = &wake, .call = Wake.ring });
647 defer rt.deinit(); 871 defer rt.deinit();
648 _ = try rt.add(opts.target, opts.session, @intCast(@max(fb_w, 0)), @intCast(@max(fb_h, 0)), metrics); 872 if (saved) |workspace| {
873 rt.workspace.deinit();
874 rt.workspace = workspace;
875 saved = null;
876 const layout = rt.workspace.layout(@intCast(@max(fb_w, 0)), @intCast(@max(fb_h, 0)), metrics);
877 try rt.restore(&layout);
878 } else if (opts.target) |target| _ = try rt.add(target, opts.session, @intCast(@max(fb_w, 0)), @intCast(@max(fb_h, 0)), metrics);
879 defer if (store) |*s| s.save(&rt.workspace) catch |err| {
880 std.debug.print("muxg: workspace not saved: {s}\n", .{@errorName(err)});
881 };
649 var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null; 882 var hook: ?HookReader = if (opts.test_fifo) |path| try HookReader.init(alloc, path) else null;
650 defer if (hook) |*h| h.deinit(); 883 defer if (hook) |*h| h.deinit();
651 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 }; 884 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, .local_target = opts.local_target, .store = if (store) |*s| s else null, .save_notice = load_notice, .save_notice_len = load_notice_len };
652 defer events.deinit(); 885 defer events.deinit();
653 try events.relayout(); 886 try events.relayout();
887 if (events.layout.len == 0) try events.openPicker(.insert);
654 var headers: [model.max_panes]Header = @splat(.{}); 888 var headers: [model.max_panes]Header = @splat(.{});
655 var popup: PopupFrame = .{}; 889 var popup: PopupFrame = .{};
656 var popup_lists: quads.Lists = .{}; 890 var popup_lists: quads.Lists = .{};
@@ -677,12 +911,14 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
677 if (usr1_seen.swap(false, .acq_rel)) report(&ring); 911 if (usr1_seen.swap(false, .acq_rel)) report(&ring);
678 const now = std.time.milliTimestamp(); 912 const now = std.time.milliTimestamp();
679 events.dirty = rt.poll(now) or events.dirty; 913 events.dirty = rt.poll(now) or events.dirty;
914 try events.pollEnd();
680 if (events.picker) |picker| if (picker.job) |job| if (job.done.load(.acquire)) { 915 if (events.picker) |picker| if (picker.job) |job| if (job.done.load(.acquire)) {
681 events.geometry_dirty = true; 916 events.geometry_dirty = true;
682 try events.refreshGeometry(); 917 try events.refreshGeometry();
683 events.dirty = (try picker.poll()) or events.dirty; 918 events.dirty = (try picker.poll()) or events.dirty;
684 }; 919 };
685 try events.finishPicker(); 920 try events.finishPicker();
921 events.saveIntent();
686 if (hook) |*h| if (h.state) |path| { 922 if (hook) |*h| if (h.state) |path| {
687 defer alloc.free(path); 923 defer alloc.free(path);
688 h.state = null; 924 h.state = null;
@@ -724,10 +960,12 @@ pub fn run(alloc: std.mem.Allocator, opts: Options) !u8 {
724 popup.len = 0; 960 popup.len = 0;
725 if (events.picker) |picker| { 961 if (events.picker) |picker| {
726 popup.set(picker); 962 popup.set(picker);
727 for (popup.lines[0..popup.len]) |*line| { 963 } else if (events.recovery) |menu| popup.setRecovery(menu, &events) else if (events.layout.len == 0) popup.setEmpty(&events);
728 var row = line.row(); 964 if (events.save_notice_len != 0 and popup.len >= 2) popup.lines[popup.len - 2].setText(events.save_notice[0..events.save_notice_len], popup.rect.w / events.metrics.cell_w);
729 try prepareRow(&cache, &row); 965 if (events.picker != null and events.notice.len != 0 and popup.len >= 2) popup.lines[popup.len - 2].setText(events.notice, popup.rect.w / events.metrics.cell_w);
730 } 966 for (popup.lines[0..popup.len]) |*line| {
967 var row = line.row();
968 try prepareRow(&cache, &row);
731 } 969 }
732 const had_blink = visible_blink; 970 const had_blink = visible_blink;
733 visible_blink = false; 971 visible_blink = false;
@@ -822,6 +1060,113 @@ test "test hook rejects zero resize and retains text exactly" {
822 try std.testing.expectEqualStrings("hi", parseHook("text:hi").?.text); 1060 try std.testing.expectEqualStrings("hi", parseHook("text:hi").?.text);
823 } 1061 }
824 1062
1063 test "delayed End refusal cannot install a hidden force menu over an active picker" {
1064 const a = std.testing.allocator;
1065 var rt = runtime.Runtime.init(a, .{});
1066 defer rt.deinit();
1067 const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
1068 const id = try rt.add(.{ .via = "cat" }, "origin", 800, 600, metrics);
1069 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1070 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 };
1071 defer events.deinit();
1072 try events.relayout();
1073 events.pending_end = .{ .key = rt.get(id).?.key, .request = 1 };
1074 try events.openPicker(.insert);
1075 rt.get(id).?.pump.mu.lock();
1076 rt.get(id).?.pump.status.ending = .{ .request = 1, .phase = .refused, .others = 1 };
1077 rt.get(id).?.pump.mu.unlock();
1078 _ = rt.poll(std.time.milliTimestamp());
1079 try events.pollEnd();
1080 try std.testing.expect(events.recovery == null and events.picker != null);
1081 try std.testing.expect(events.pending_end == null);
1082 events.picker.?.level = .session_name;
1083 var ev = std.mem.zeroes(c.SDL_Event);
1084 ev.key.type = c.SDL_EVENT_KEY_DOWN;
1085 ev.key.key = c.SDLK_X;
1086 _ = try events.handle(ev);
1087 ev.text.type = c.SDL_EVENT_TEXT_INPUT;
1088 ev.text.text = "x";
1089 _ = try events.handle(ev);
1090 try std.testing.expectEqualStrings("x", events.picker.?.input.items);
1091 try std.testing.expectEqual(@as(u64, 1), rt.get(id).?.pump.state().ending.request);
1092 }
1093
1094 test "delayed End refusal respects changed focus and recovery context names its captured pane" {
1095 const a = std.testing.allocator;
1096 var rt = runtime.Runtime.init(a, .{});
1097 defer rt.deinit();
1098 const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
1099 const first = try rt.add(.{ .via = "cat" }, "end-target", 800, 600, metrics);
1100 const second = try rt.add(.{ .via = "cat" }, "focused", 800, 600, metrics);
1101 const key = rt.get(first).?.key;
1102 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1103 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 };
1104 defer events.deinit();
1105 try events.relayout();
1106 events.pending_end = .{ .key = key, .request = 1 };
1107 rt.get(first).?.status.ending = .{ .request = 1, .phase = .refused, .others = 1 };
1108 try events.pollEnd();
1109 try std.testing.expectEqual(second, rt.workspace.tab().focus.?);
1110 try std.testing.expect(events.recovery == null and events.pending_end == null);
1111
1112 // Returning to the target permits its explicit confirmation. Even if focus
1113 // later moves, the rendered context follows the menu's captured attachment.
1114 _ = rt.workspace.focus(first);
1115 events.pending_end = .{ .key = key, .request = 1 };
1116 try events.pollEnd();
1117 try std.testing.expectEqual(key, events.recovery.?.key);
1118 try std.testing.expectEqual(@as(usize, 0), events.recovery.?.selected);
1119 _ = rt.workspace.focus(second);
1120 var popup: PopupFrame = .{};
1121 popup.setRecovery(events.recovery.?, &events);
1122 const context = &popup.lines[popup.len - 3];
1123 try std.testing.expectEqualStrings("end-target on cat", context.bytes[0..context.len]);
1124 try std.testing.expectEqualStrings("Cancel", popup.lines[1].bytes[0..popup.lines[1].len]);
1125 try std.testing.expectEqualStrings("End for all clients", popup.lines[2].bytes[0..popup.lines[2].len]);
1126 }
1127
1128 test "held detach removes one pane and recovery Enter stays consumed after closing" {
1129 const a = std.testing.allocator;
1130 var rt = runtime.Runtime.init(a, .{});
1131 defer rt.deinit();
1132 const metrics: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
1133 const first = try rt.add(.{ .via = "cat" }, "first", 800, 600, metrics);
1134 _ = try rt.add(.{ .via = "cat" }, "second", 800, 600, metrics);
1135 var wake: Wake = .{ .event_type = c.SDL_EVENT_USER };
1136 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, .command_mode = true };
1137 defer events.deinit();
1138 try events.relayout();
1139 var ev = std.mem.zeroes(c.SDL_Event);
1140 ev.key.type = c.SDL_EVENT_KEY_DOWN;
1141 ev.key.key = c.SDLK_D;
1142 _ = try events.handle(ev);
1143 ev.key.repeat = true;
1144 _ = try events.handle(ev);
1145 try std.testing.expectEqual(@as(usize, 1), events.layout.len);
1146 try std.testing.expect(rt.get(first) != null);
1147 ev.key.type = c.SDL_EVENT_KEY_UP;
1148 _ = try events.handle(ev);
1149 events.recovery = .{ .kind = .force_end, .key = rt.get(first).?.key };
1150 ev.key.type = c.SDL_EVENT_KEY_DOWN;
1151 ev.key.key = c.SDLK_RETURN;
1152 ev.key.repeat = false;
1153 _ = try events.handle(ev); // Cancel is the default action.
1154 ev.key.repeat = true;
1155 _ = try events.handle(ev);
1156 try std.testing.expect(events.recovery == null and events.pending_end == null and events.suppress_text);
1157 try std.testing.expectEqual(client.session_pump.EndPhase.idle, rt.get(first).?.pump.state().ending.phase);
1158 // A narrow connecting pane must show save failure before its long label.
1159 rt.workspace.pane(first).?.identity.label = "very-long-host-and-session-label-that-would-otherwise-hide-the-save-error";
1160 const warning = "Workspace not saved: AccessDenied";
1161 @memcpy(events.save_notice[0..warning.len], warning);
1162 events.save_notice_len = warning.len;
1163 var placement = events.layout.get(first).?;
1164 placement.header.w = 32 * metrics.cell_w;
1165 var header: Header = .{};
1166 header.set(&events, placement, rt.get(first).?);
1167 try std.testing.expect(std.mem.startsWith(u8, header.bytes[0..header.len], "> Workspace not saved:"));
1168 }
1169
825 const Header = struct { 1170 const Header = struct {
826 bytes: [512]u8 = undefined, 1171 bytes: [512]u8 = undefined,
827 cells: [512]term.grid.Cell = undefined, 1172 cells: [512]term.grid.Cell = undefined,
@@ -829,7 +1174,7 @@ const Header = struct {
829 fn set(self: *Header, events: *Events, p: model.Placement, live: *const runtime.Live) void { 1174 fn set(self: *Header, events: *Events, p: model.Placement, live: *const runtime.Live) void {
830 const focused = events.rt.workspace.tab().focus == p.id; 1175 const focused = events.rt.workspace.tab().focus == p.id;
831 const pending = events.rt.workspace.tab().pending; 1176 const pending = events.rt.workspace.tab().pending;
832 const hint = if (focused and events.resize_mode) (if (events.notice.len != 0) events.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.command_mode) " [command: v/b split, h/j/k/l focus, r resize, 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; 1177 const hint = if (events.save_notice_len != 0) events.save_notice[0..events.save_notice_len] else if (focused and events.resize_mode) (if (events.notice.len != 0) events.notice else " [resize: arrows/hjkl move divider, Enter/Esc finish]") else if (focused and events.command_mode) " [command: v/b split, h/j/k/l focus, r resize, 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;
833 const label = events.rt.workspace.pane(p.id).?.identity.label; 1178 const label = events.rt.workspace.pane(p.id).?.identity.label;
834 var status_buf: [48]u8 = undefined; 1179 var status_buf: [48]u8 = undefined;
835 const status: []const u8 = switch (live.status.phase) { 1180 const status: []const u8 = switch (live.status.phase) {
@@ -842,7 +1187,10 @@ const Header = struct {
842 .taken => "[taken by another client] ", 1187 .taken => "[taken by another client] ",
843 .exited => std.fmt.bufPrint(&status_buf, "[exited ({d})] ", .{live.status.exit_code}) catch unreachable, 1188 .exited => std.fmt.bufPrint(&status_buf, "[exited ({d})] ", .{live.status.exit_code}) catch unreachable,
844 }; 1189 };
845 const text = std.fmt.bufPrint(&self.bytes, "{s}{s}{s} {s}", .{ if (focused) "> " else " ", status, hint, label }) catch self.bytes[0..]; 1190 const text = if (events.save_notice_len != 0)
1191 std.fmt.bufPrint(&self.bytes, "{s}{s} {s}{s}", .{ if (focused) "> " else " ", hint, status, label }) catch self.bytes[0..]
1192 else
1193 std.fmt.bufPrint(&self.bytes, "{s}{s}{s} {s}", .{ if (focused) "> " else " ", status, hint, label }) catch self.bytes[0..];
846 self.setText(text, p.header.w / events.metrics.cell_w); 1194 self.setText(text, p.header.w / events.metrics.cell_w);
847 } 1195 }
848 fn setText(self: *Header, text: []const u8, cols: usize) void { 1196 fn setText(self: *Header, text: []const u8, cols: usize) void {
@@ -914,7 +1262,15 @@ fn writeState(alloc: std.mem.Allocator, path: []const u8, events: *Events) !void
914 for (rows, 0..) |*row, i| row.* = .{ .label = picker.rowLabel(i), .rect = view.rowRect(i) }; 1262 for (rows, 0..) |*row, i| row.* = .{ .label = picker.rowLabel(i), .rect = view.rowRect(i) };
915 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 }; 1263 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 };
916 } 1264 }
917 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, .resize_mode = events.resize_mode, .drag = events.drag, .dividers = events.layout.boundaries(), .notice = events.notice, .picker = picker_state, .panes = panes[0..events.layout.len] }, .{}); 1265 const RecoveryState = struct { kind: []const u8, rows: []const PopupRow, selected: usize, notice: []const u8, rect: model.Rect };
1266 var recovery_state: ?RecoveryState = null;
1267 if (events.recovery) |menu| {
1268 const view = menu.view(@intCast(@max(events.fb_w, 0)), @intCast(@max(events.fb_h, 0)), events.metrics);
1269 const rows = try a.alloc(PopupRow, menu.count());
1270 for (rows, 0..) |*row, i| row.* = .{ .label = menu.label(i), .rect = view.rowRect(i) };
1271 recovery_state = .{ .kind = @tagName(menu.kind), .rows = rows, .selected = menu.selected, .notice = menu.notice[0..menu.notice_len], .rect = view.rect };
1272 }
1273 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, .resize_mode = events.resize_mode, .drag = events.drag, .dividers = events.layout.boundaries(), .notice = if (events.save_notice_len != 0) events.save_notice[0..events.save_notice_len] else events.notice, .persistent = events.store != null, .save_enabled = if (events.store) |store| store.writable else false, .pending_end = events.pending_end, .recovery = recovery_state, .picker = picker_state, .panes = panes[0..events.layout.len] }, .{});
918 try writeArtifact(a, path, &.{bytes}); 1274 try writeArtifact(a, path, &.{bytes});
919 } 1275 }
920 fn writePixels(alloc: std.mem.Allocator, path: []const u8, width: u32, height: u32, pixels: []const u8) !void { 1276 fn writePixels(alloc: std.mem.Allocator, path: []const u8, width: u32, height: u32, pixels: []const u8) !void {
@@ -975,7 +1331,7 @@ test "resize mode repeats move one cell without leaking held directions after ex
975 try std.testing.expectEqual(divider.rect, events.layout.boundaries()[0].rect); 1331 try std.testing.expectEqual(divider.rect, events.layout.boundaries()[0].rect);
976 ev.key.type = c.SDL_EVENT_KEY_UP; 1332 ev.key.type = c.SDL_EVENT_KEY_UP;
977 _ = try events.handle(ev); 1333 _ = try events.handle(ev);
978 try std.testing.expect(!events.resize_held.contains(c.SDLK_H)); 1334 try std.testing.expect(!events.modal_held.contains(c.SDLK_H));
979 // Physical aliases and ordinary modal text each retain their own release. 1335 // Physical aliases and ordinary modal text each retain their own release.
980 try events.command(c.SDLK_R); 1336 try events.command(c.SDLK_R);
981 ev.key.type = c.SDL_EVENT_KEY_DOWN; 1337 ev.key.type = c.SDL_EVENT_KEY_DOWN;
@@ -1214,13 +1570,49 @@ const PopupFrame = struct {
1214 len: usize = 0, 1570 len: usize = 0,
1215 rect: model.Rect = .{}, 1571 rect: model.Rect = .{},
1216 selected_line: ?usize = null, 1572 selected_line: ?usize = null,
1573 fn reset(self: *PopupFrame, rect: model.Rect, len: usize, cell_w: u16) u32 {
1574 self.rect = rect;
1575 self.len = len;
1576 self.selected_line = null;
1577 const cols = rect.w / cell_w;
1578 for (self.lines[0..len]) |*line| line.setText("", cols);
1579 return cols;
1580 }
1581 fn setRecovery(self: *PopupFrame, menu: Recovery, events: *Events) void {
1582 const view = menu.view(@intCast(@max(events.fb_w, 0)), @intCast(@max(events.fb_h, 0)), events.metrics);
1583 const cols = self.reset(view.rect, menu.count() + 4, events.metrics.cell_w);
1584 self.selected_line = menu.selected + 1;
1585 self.lines[0].setText(menu.title(), cols);
1586 for (0..menu.count()) |i| self.lines[i + 1].setText(menu.label(i), cols);
1587 if (events.rt.accepts(menu.key)) {
1588 const identity = &events.rt.workspace.pane(menu.key.pane).?.identity;
1589 const host = switch (identity.target) {
1590 .sock, .via => |text| text,
1591 .quic => |target| target.host_port,
1592 .hand => |target| target.host,
1593 };
1594 var context: [512]u8 = undefined;
1595 const text = std.fmt.bufPrint(&context, "{s} on {s}", .{ identity.session, host[0..@min(host.len, context.len - identity.session.len - 4)] }) catch unreachable;
1596 self.lines[self.len - 3].setText(text, cols);
1597 } else self.lines[self.len - 3].setText("The pane changed", cols);
1598 self.lines[self.len - 2].setText(menu.notice[0..menu.notice_len], cols);
1599 self.lines[self.len - 1].setText("Up/Down or j/k choose | Enter selects | Esc closes", cols);
1600 }
1601 fn setEmpty(self: *PopupFrame, events: *Events) void {
1602 const width: u32 = @intCast(@max(events.fb_w, 0));
1603 const height: u32 = @intCast(@max(events.fb_h, 0));
1604 const w = @min(width, @as(u32, events.metrics.cell_w) * 74);
1605 const h = @min(height, @as(u32, events.metrics.cell_h) * 4);
1606 const cols = self.reset(.{ .x = (width - w) / 2, .y = (height - h) / 2, .w = w, .h = h }, 4, events.metrics.cell_w);
1607 self.selected_line = 1;
1608 self.lines[0].setText("No panes", cols);
1609 self.lines[1].setText("Add pane", cols);
1610 self.lines[2].setText(events.notice, cols);
1611 self.lines[3].setText("Click Add pane or prefix Enter to choose a session", cols);
1612 }
1217 fn set(self: *PopupFrame, picker: *const picker_mod.Picker) void { 1613 fn set(self: *PopupFrame, picker: *const picker_mod.Picker) void {
1218 const view = picker.view(); 1614 const view = picker.view();
1219 self.rect = view.rect; 1615 const cols = self.reset(view.rect, view.shown + 4, picker.metrics.cell_w);
1220 self.len = view.shown + 4;
1221 self.selected_line = null;
1222 const cols = view.rect.w / picker.metrics.cell_w;
1223 for (self.lines[0..self.len]) |*line| line.setText("", cols);
1224 self.lines[0].setText(picker.title(), cols); 1616 self.lines[0].setText(picker.title(), cols);
1225 for (0..view.shown) |i| { 1617 for (0..view.shown) |i| {
1226 const index = view.first + i; 1618 const index = view.first + i;
src/gui/native.zig
Old New
@@ -14,6 +14,7 @@ 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 picker = @import("picker.zig");
17 pub const persistence = @import("persistence.zig");
17 pub const bench = @import("bench.zig"); 18 pub const bench = @import("bench.zig");
18 pub const atlas = @import("atlas.zig"); 19 pub const atlas = @import("atlas.zig");
19 pub const font = @import("font.zig"); 20 pub const font = @import("font.zig");
@@ -31,6 +32,7 @@ test {
31 _ = workspace; 32 _ = workspace;
32 _ = runtime; 33 _ = runtime;
33 _ = picker; 34 _ = picker;
35 _ = persistence;
34 _ = bench; 36 _ = bench;
35 _ = atlas; 37 _ = atlas;
36 _ = font; 38 _ = font;
src/gui/persistence.zig
Old New
@@ -0,0 +1,378 @@
1 //! Versioned native workspace intent, independent of terminal layout state.
2 //! Decode validates the complete graph before owning panes; a failed load seals
3 //! the writer for this run so a fallback workspace cannot replace the evidence.
4 const std = @import("std");
5 const client = @import("client");
6 const model = @import("workspace.zig");
7 const Tree = @FieldType(model.Tab, "tree");
8 const Node = @typeInfo(@FieldType(Tree, "nodes")).array.child;
9 pub const max_bytes = 1024 * 1024;
10 const SavedPane = struct { id: model.PaneId, target: client.Target, session: []const u8 };
11 const SavedTree = struct { root: ?u8, nodes: []const Node, next_divider_id: model.DividerId };
12 const SavedTab = struct { id: model.TabId, focus: ?model.PaneId, tree: SavedTree, panes: []const SavedPane };
13 const Document = struct { version: u32, active_tab_id: model.TabId, next_pane_id: model.PaneId, tabs: []const SavedTab };
14
15 pub fn encode(alloc: std.mem.Allocator, workspace: *model.Workspace) ![]u8 {
16 var arena = std.heap.ArenaAllocator.init(alloc);
17 defer arena.deinit();
18 const scratch = arena.allocator();
19 const cwd = try std.process.getCwdAlloc(scratch);
20 var panes: [model.max_panes]SavedPane = undefined;
21 var len: usize = 0;
22 const tab = workspace.tab();
23 for (tab.panes) |entry| if (entry) |pane| {
24 panes[len] = .{ .id = pane.id, .target = pane.identity.target, .session = pane.identity.session };
25 switch (panes[len].target) {
26 .sock => |path| panes[len].target.sock = try absolutePath(scratch, cwd, path),
27 .quic => |*q| q.key_path = try absolutePath(scratch, cwd, q.key_path),
28 .hand => |*h| if (h.cache_path) |path| {
29 h.cache_path = try absolutePath(scratch, cwd, path);
30 },
31 .via => {},
32 }
33 // These describe a prior invocation, not a saved transport identity.
34 if (panes[len].target == .hand) {
35 panes[len].target.hand.asked = false;
36 panes[len].target.hand.narrate = false;
37 panes[len].target.hand.ask_sock = null;
38 panes[len].target.hand.ask_exe = "";
39 }
40 len += 1;
41 };
42 const tabs = [_]SavedTab{.{ .id = tab.id, .focus = tab.focus, .tree = .{ .root = tab.tree.root, .nodes = &tab.tree.nodes, .next_divider_id = tab.tree.next_divider_id }, .panes = panes[0..len] }};
43 return std.json.Stringify.valueAlloc(alloc, Document{ .version = 1, .active_tab_id = workspace.active_tab_id, .next_pane_id = workspace.next_pane_id, .tabs = &tabs }, .{ .whitespace = .indent_2 });
44 }
45
46 fn absolutePath(alloc: std.mem.Allocator, cwd: []const u8, path: []const u8) ![]const u8 {
47 // Preserve symlink/.. semantics; resolving path components lexically would
48 // change which file the live transport already uses.
49 return if (std.fs.path.isAbsolute(path)) path else try std.fs.path.join(alloc, &.{ cwd, path });
50 }
51
52 fn validText(text: []const u8) bool {
53 return text.len > 0 and std.mem.indexOfScalar(u8, text, 0) == null;
54 }
55 fn validTarget(target: client.Target) bool {
56 return switch (target) {
57 .sock => |s| validText(s) and std.fs.path.isAbsolute(s),
58 .via => |s| validText(s),
59 .quic => |q| validText(q.host_port) and validText(q.key_path) and std.fs.path.isAbsolute(q.key_path) and q.deadline_ms > 0,
60 .hand => |h| blk: {
61 if (!validText(h.host) or h.ssh_argv.len == 0 or h.deadline_ms == 0) break :blk false;
62 for (h.ssh_argv) |arg| if (!validText(arg)) break :blk false;
63 for (h.asked_argv) |arg| if (!validText(arg)) break :blk false;
64 if (h.cache_path) |path| if (!validText(path) or !std.fs.path.isAbsolute(path)) break :blk false;
65 break :blk true;
66 },
67 };
68 }
69 const Validation = struct {
70 tab: SavedTab,
71 seen: [model.max_panes * 2 - 1]bool = @splat(false),
72 leaves: [model.max_panes]bool = @splat(false),
73 max_divider: u64 = 0,
74 fn visit(self: *Validation, index: u8) error{InvalidWorkspace}!void {
75 if (index >= self.tab.tree.nodes.len or self.seen[index]) return error.InvalidWorkspace;
76 self.seen[index] = true;
77 switch (self.tab.tree.nodes[index]) {
78 .empty => return error.InvalidWorkspace,
79 .leaf => |id| {
80 for (self.tab.panes, 0..) |pane, i| if (pane.id == id) {
81 if (self.leaves[i]) return error.InvalidWorkspace;
82 self.leaves[i] = true;
83 return;
84 };
85 return error.InvalidWorkspace;
86 },
87 .split => |s| {
88 if (s.id == 0 or s.first == 0 or s.first >= s.total) return error.InvalidWorkspace;
89 for (self.tab.tree.nodes, 0..) |other, i| if (i != index and other == .split and other.split.id == s.id) return error.InvalidWorkspace;
90 self.max_divider = @max(self.max_divider, s.id);
91 try self.visit(s.a);
92 try self.visit(s.b);
93 },
94 }
95 }
96 };
97
98 pub fn decode(alloc: std.mem.Allocator, bytes: []const u8) !model.Workspace {
99 if (bytes.len > max_bytes) return error.WorkspaceTooLarge;
100 const parsed = try std.json.parseFromSlice(Document, alloc, bytes, .{ .allocate = .alloc_always });
101 defer parsed.deinit();
102 const doc = parsed.value;
103 if (doc.version != 1) return error.UnsupportedWorkspaceVersion;
104 if (doc.tabs.len != 1) return error.UnsupportedWorkspaceTabs;
105 const tab = doc.tabs[0];
106 if (tab.id == 0 or doc.active_tab_id != tab.id or tab.panes.len > model.max_panes or tab.tree.nodes.len != model.max_panes * 2 - 1) return error.InvalidWorkspace;
107 var max_pane: u64 = 0;
108 for (tab.panes, 0..) |pane, i| {
109 if (pane.id == 0 or !@import("term").protocol.validSessionName(pane.session) or !validTarget(pane.target)) return error.InvalidWorkspace;
110 for (tab.panes[0..i]) |other| if (other.id == pane.id) return error.InvalidWorkspace;
111 max_pane = @max(max_pane, pane.id);
112 }
113 if (doc.next_pane_id <= max_pane) return error.InvalidWorkspace;
114 var validation: Validation = .{ .tab = tab };
115 if (tab.tree.root) |root| try validation.visit(root);
116 for (tab.tree.nodes, 0..) |node, i| if ((node != .empty) != validation.seen[i]) return error.InvalidWorkspace;
117 for (validation.leaves[0..tab.panes.len]) |seen| if (!seen) return error.InvalidWorkspace;
118 if (tab.tree.next_divider_id <= validation.max_divider) return error.InvalidWorkspace;
119 if (tab.focus) |focus| {
120 var found = false;
121 for (tab.panes) |pane| if (pane.id == focus) {
122 found = true;
123 };
124 if (!found) return error.InvalidWorkspace;
125 } else if (tab.panes.len != 0) return error.InvalidWorkspace;
126 var workspace = model.Workspace.init(alloc);
127 errdefer workspace.deinit();
128 workspace.active_tab_id = doc.active_tab_id;
129 workspace.next_pane_id = doc.next_pane_id;
130 workspace.tab().id = tab.id;
131 workspace.tab().focus = tab.focus;
132 workspace.tab().tree.root = tab.tree.root;
133 workspace.tab().tree.next_divider_id = tab.tree.next_divider_id;
134 @memcpy(&workspace.tab().tree.nodes, tab.tree.nodes);
135 for (tab.panes, 0..) |saved, i| {
136 const pane = try alloc.create(model.Pane);
137 errdefer alloc.destroy(pane);
138 var target = saved.target;
139 if (target == .hand) {
140 target.hand.asked = false;
141 target.hand.narrate = false;
142 target.hand.ask_sock = null;
143 target.hand.ask_exe = "";
144 }
145 pane.* = .{ .id = saved.id, .identity = try model.Identity.init(alloc, target, saved.session) };
146 workspace.tab().panes[i] = pane;
147 }
148 return workspace;
149 }
150
151 pub const Store = struct {
152 alloc: std.mem.Allocator,
153 path: []const u8,
154 lock: std.fs.File,
155 writable: bool = true,
156 pub fn open(alloc: std.mem.Allocator, path: []const u8) !Store {
157 const owned = try alloc.dupe(u8, path);
158 errdefer alloc.free(owned);
159 if (std.fs.path.dirname(path)) |parent| {
160 if (try std.fs.cwd().makePathStatus(parent) == .created) {
161 var directory = try std.fs.cwd().openDir(parent, .{ .iterate = true });
162 defer directory.close();
163 try directory.chmod(0o700);
164 }
165 }
166 const lock_path = try std.fmt.allocPrint(alloc, "{s}.lock", .{path});
167 defer alloc.free(lock_path);
168 const lock = std.fs.cwd().createFile(lock_path, .{ .truncate = false, .mode = 0o600, .lock = .exclusive, .lock_nonblocking = true }) catch |err| switch (err) {
169 error.WouldBlock => return error.WorkspaceAlreadyOpen,
170 else => return err,
171 };
172 return .{ .alloc = alloc, .path = owned, .lock = lock };
173 }
174 pub fn deinit(self: *Store) void {
175 // Never unlink: waiters must always lock this same inode, even when the
176 // JSON next to it has just been replaced by an atomic rename.
177 self.lock.close();
178 self.alloc.free(self.path);
179 }
180 pub fn load(self: *Store) !?model.Workspace {
181 errdefer self.writable = false;
182 const bytes = std.fs.cwd().readFileAlloc(self.alloc, self.path, max_bytes) catch |err| switch (err) {
183 error.FileNotFound => return null,
184 else => return err,
185 };
186 defer self.alloc.free(bytes);
187 return try decode(self.alloc, bytes);
188 }
189 pub fn save(self: *Store, workspace: *model.Workspace) !void {
190 if (!self.writable) return error.WorkspacePreserved;
191 const bytes = try encode(self.alloc, workspace);
192 defer self.alloc.free(bytes);
193 if (bytes.len > max_bytes) return error.WorkspaceTooLarge;
194 var buffer: [4096]u8 = undefined;
195 var file = try std.fs.cwd().atomicFile(self.path, .{ .mode = 0o600, .write_buffer = &buffer });
196 defer file.deinit();
197 try file.file_writer.interface.writeAll(bytes);
198 try file.flush();
199 try file.file_writer.file.sync();
200 try file.renameIntoPlace();
201 }
202 };
203
204 test "native state preserves nested weights and identity without transient modes" {
205 const a = std.testing.allocator;
206 var workspace = model.Workspace.init(a);
207 defer workspace.deinit();
208 const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16, .divider = 2 };
209 workspace.commit(try workspace.prepare(.{ .sock = "/tmp/native-state-fixture" }, "alpha", 1000, 800, metrics));
210 workspace.arm(.beside);
211 workspace.commit(try workspace.prepare(.{ .quic = .{ .host_port = "host:444", .key_path = "/key" } }, "beta", 1000, 800, metrics));
212 workspace.arm(.stacked);
213 workspace.commit(try workspace.prepare(.{ .sock = "/tmp/native-state-fixture" }, "gamma", 1000, 800, metrics));
214 _ = workspace.resizeFocused(.up, 1000, 800, metrics);
215 _ = workspace.layout(8, 8, metrics);
216 workspace.arm(.beside);
217 const bytes = try encode(a, &workspace);
218 defer a.free(bytes);
219 var restored = try decode(a, bytes);
220 defer restored.deinit();
221 try std.testing.expectEqual(workspace.tab().focus, restored.tab().focus);
222 try std.testing.expectEqual(workspace.next_pane_id, restored.next_pane_id);
223 try std.testing.expect(restored.tab().pending == null);
224 try std.testing.expectEqualDeep(workspace.tab().tree, restored.tab().tree);
225 try std.testing.expectEqualDeep(workspace.layout(1700, 1200, metrics), restored.layout(1700, 1200, metrics));
226 }
227
228 test "native decoder rejects corrupt graphs, targets and unsupported state" {
229 const a = std.testing.allocator;
230 var workspace = model.Workspace.init(a);
231 defer workspace.deinit();
232 const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16, .divider = 2 };
233 workspace.commit(try workspace.prepare(.{ .sock = "/tmp/state" }, "a", 1000, 800, metrics));
234 workspace.commit(try workspace.prepare(.{ .sock = "/tmp/state" }, "b", 1000, 800, metrics));
235 const bytes = try encode(a, &workspace);
236 defer a.free(bytes);
237 const parsed = try std.json.parseFromSlice(Document, a, bytes, .{});
238 defer parsed.deinit();
239 for (0..12) |case| {
240 var doc = parsed.value;
241 var tab = doc.tabs[0];
242 var nodes: [model.max_panes * 2 - 1]Node = undefined;
243 @memcpy(&nodes, tab.tree.nodes);
244 var panes: [2]SavedPane = undefined;
245 @memcpy(&panes, tab.panes);
246 tab.tree.nodes = &nodes;
247 tab.panes = &panes;
248 doc.tabs = @as(*const [1]SavedTab, @ptrCast(&tab));
249 switch (case) {
250 0 => doc.version = 999,
251 1 => doc.active_tab_id = 999,
252 2 => panes[1].id = panes[0].id,
253 3 => nodes[0].split.a = 0,
254 4 => nodes[0].split.b = nodes[0].split.a,
255 5 => nodes[0].split.first = 0,
256 6 => nodes[0].split.first = nodes[0].split.total,
257 7 => nodes[62] = .{ .leaf = panes[0].id },
258 8 => doc.next_pane_id = panes[1].id,
259 9 => tab.focus = 999,
260 10 => panes[0].target = .{ .sock = "relative.sock" },
261 11 => panes[0].target = .{ .hand = .{ .host = "host", .ssh_argv = &.{"ssh"}, .cache_path = "/bad\x00path" } },
262 else => unreachable,
263 }
264 const bad = try std.json.Stringify.valueAlloc(a, doc, .{});
265 defer a.free(bad);
266 if (decode(a, bad)) |value| {
267 var unexpected = value;
268 unexpected.deinit();
269 return error.CorruptStateAccepted;
270 } else |_| {}
271 }
272 }
273
274 test "native store locks separate inode and preserves malformed or failed saves" {
275 const a = std.testing.allocator;
276 var tmp = std.testing.tmpDir(.{});
277 defer tmp.cleanup();
278 const directory = try tmp.dir.realpathAlloc(a, ".");
279 defer a.free(directory);
280 const path = try std.fs.path.join(a, &.{ directory, "native-workspace.json" });
281 defer a.free(path);
282 var store = try Store.open(a, path);
283 defer store.deinit();
284 try std.testing.expect((try store.load()) == null);
285 try std.testing.expectError(error.WorkspaceAlreadyOpen, Store.open(a, path));
286 var workspace = model.Workspace.init(a);
287 defer workspace.deinit();
288 try store.save(&workspace);
289 try std.testing.expectError(error.WorkspaceAlreadyOpen, Store.open(a, path));
290 const original = try tmp.dir.readFileAlloc(a, "native-workspace.json", max_bytes);
291 defer a.free(original);
292 // Make atomic rename fail with a directory at the destination. The prior
293 // file is retained separately; this case checks failure and temp cleanup.
294 try tmp.dir.rename("native-workspace.json", "good.json");
295 try tmp.dir.makeDir("native-workspace.json");
296 if (store.save(&workspace)) |_| return error.FailedSaveAccepted else |_| {}
297 const retained = try tmp.dir.readFileAlloc(a, "good.json", max_bytes);
298 defer a.free(retained);
299 try std.testing.expectEqualStrings(original, retained);
300 var listing = try tmp.dir.openDir(".", .{ .iterate = true });
301 defer listing.close();
302 var iterator = listing.iterate();
303 var entries: usize = 0;
304 while (try iterator.next()) |_| entries += 1;
305 try std.testing.expectEqual(@as(usize, 3), entries);
306 try tmp.dir.deleteDir("native-workspace.json");
307 try tmp.dir.writeFile(.{ .sub_path = "native-workspace.json", .data = "{broken evidence" });
308 if (store.load()) |value| {
309 if (value) |ws| {
310 var unexpected = ws;
311 unexpected.deinit();
312 }
313 return error.CorruptStateAccepted;
314 } else |_| {}
315 try std.testing.expectError(error.WorkspacePreserved, store.save(&workspace));
316 const malformed = try tmp.dir.readFileAlloc(a, "native-workspace.json", max_bytes);
317 defer a.free(malformed);
318 try std.testing.expectEqualStrings("{broken evidence", malformed);
319 }
320
321 test "native saved filesystem references are absolute" {
322 const a = std.testing.allocator;
323 var workspace = model.Workspace.init(a);
324 defer workspace.deinit();
325 const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16, .divider = 2 };
326 workspace.commit(try workspace.prepare(.{ .sock = "fixtures/socket" }, "a", 1000, 800, metrics));
327 workspace.commit(try workspace.prepare(.{ .quic = .{ .host_port = "host:44", .key_path = "fixtures/key" } }, "b", 1000, 800, metrics));
328 const bytes = try encode(a, &workspace);
329 defer a.free(bytes);
330 var restored = try decode(a, bytes);
331 defer restored.deinit();
332 const cwd = try std.process.getCwdAlloc(a);
333 defer a.free(cwd);
334 const sock = try std.fs.path.join(a, &.{ cwd, "fixtures/socket" });
335 defer a.free(sock);
336 const key = try std.fs.path.join(a, &.{ cwd, "fixtures/key" });
337 defer a.free(key);
338 try std.testing.expectEqualStrings(sock, restored.pane(1).?.identity.target.sock);
339 try std.testing.expectEqualStrings(key, restored.pane(2).?.identity.target.quic.key_path);
340 }
341
342 fn decodeWithAllocator(alloc: std.mem.Allocator, bytes: []const u8) !void {
343 var workspace = try decode(alloc, bytes);
344 defer workspace.deinit();
345 }
346
347 test "native restore cleans up every partially allocated pane" {
348 const a = std.testing.allocator;
349 var workspace = model.Workspace.init(a);
350 defer workspace.deinit();
351 const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16, .divider = 2 };
352 workspace.commit(try workspace.prepare(.{ .sock = "/tmp/state" }, "alpha", 1000, 800, metrics));
353 workspace.commit(try workspace.prepare(.{ .hand = .{ .host = "host", .ssh_argv = &.{ "ssh", "host" }, .cache_path = "/tmp/cache" } }, "beta", 1000, 800, metrics));
354 const bytes = try encode(a, &workspace);
355 defer a.free(bytes);
356 try std.testing.checkAllAllocationFailures(a, decodeWithAllocator, .{bytes});
357 }
358
359 test "exhausted native identity counters restore without allowing an overflow" {
360 const a = std.testing.allocator;
361 var workspace = model.Workspace.init(a);
362 defer workspace.deinit();
363 const metrics: model.Metrics = .{ .cell_w = 8, .cell_h = 16, .divider = 2 };
364 workspace.next_pane_id = std.math.maxInt(u64);
365 const empty = try encode(a, &workspace);
366 defer a.free(empty);
367 var restored = try decode(a, empty);
368 defer restored.deinit();
369 try std.testing.expectError(error.IdExhausted, restored.prepare(.{ .sock = "/tmp/state" }, "a", 1000, 800, metrics));
370 workspace.next_pane_id = 1;
371 workspace.commit(try workspace.prepare(.{ .sock = "/tmp/state" }, "a", 1000, 800, metrics));
372 workspace.tab().tree.next_divider_id = std.math.maxInt(u64);
373 const full = try encode(a, &workspace);
374 defer a.free(full);
375 var exhausted = try decode(a, full);
376 defer exhausted.deinit();
377 try std.testing.expectError(error.IdExhausted, exhausted.prepare(.{ .sock = "/tmp/state" }, "b", 1000, 800, metrics));
378 }
src/gui/picker.zig
Old New
@@ -20,12 +20,14 @@ pub const View = struct {
20 } 20 }
21 }; 21 };
22 pub const Picker = struct { 22 pub const Picker = struct {
23 pub const Mode = enum { insert, replace };
23 alloc: std.mem.Allocator, 24 alloc: std.mem.Allocator,
24 arena: std.heap.ArenaAllocator, 25 arena: std.heap.ArenaAllocator,
25 rt: *runtime.Runtime, 26 rt: *runtime.Runtime,
26 origin: model.Attachment, 27 origin: ?model.Attachment,
27 origin_tab: model.TabId, 28 origin_tab: model.TabId,
28 pending: model.Pending, 29 pending: ?model.Pending,
30 mode: Mode = .insert,
29 ticket: discovery.Ticket, 31 ticket: discovery.Ticket,
30 next_generation: *u64, 32 next_generation: *u64,
31 level: Level = .hosts, 33 level: Level = .hosts,
@@ -50,11 +52,17 @@ pub const Picker = struct {
50 inserted: bool = false, 52 inserted: bool = false,
51 53
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 { 54 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); 55 return initMode(alloc, rt, next_generation, key_path, width, height, metrics, wake_ctx, wake, .insert);
54 const pending = rt.workspace.tab().pending orelse return error.MissingPane; 56 }
55 const pane = rt.workspace.pane(pending.pane) orelse return error.MissingPane; 57 pub fn initMode(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, mode: Mode) !*Picker {
58 if (mode == .insert and rt.workspace.tab().pending == null) rt.workspace.arm(.beside);
59 const pending = rt.workspace.tab().pending;
60 const id = if (mode == .replace) rt.workspace.tab().focus else if (pending) |p| p.pane else null;
61 const pane = if (id) |p| rt.workspace.pane(p) orelse return error.MissingPane else null;
62 if (mode == .replace and pane == null) return error.MissingPane;
63 const origin: ?model.Attachment = if (pane) |p| .{ .pane = p.id, .generation = p.generation } else null;
56 const self = try alloc.create(Picker); 64 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 }; 65 self.* = .{ .alloc = alloc, .arena = std.heap.ArenaAllocator.init(alloc), .rt = rt, .origin = origin, .origin_tab = rt.workspace.active_tab_id, .pending = pending, .mode = mode, .ticket = .{ .generation = next_generation.*, .owner = if (origin) |v| v.pane else 0, .attachment_generation = if (origin) |v| v.generation else 0 }, .next_generation = next_generation, .key_path = key_path, .width = width, .height = height, .metrics = metrics, .wake_ctx = wake_ctx, .wake = wake };
58 next_generation.* += 1; 66 next_generation.* += 1;
59 errdefer self.deinit(); 67 errdefer self.deinit();
60 const a = self.arena.allocator(); 68 const a = self.arena.allocator();
@@ -72,7 +80,11 @@ pub const Picker = struct {
72 try self.hosts.append(a, .{ .label = line, .target = target }); 80 try self.hosts.append(a, .{ .label = line, .target = target });
73 } 81 }
74 } 82 }
75 const current = pane.identity.target; 83 if (pane) |p| try self.includeTarget(p.identity.target);
84 return self;
85 }
86 pub fn includeTarget(self: *Picker, current: client.Target) !void {
87 const a = self.arena.allocator();
76 const label = switch (current) { 88 const label = switch (current) {
77 .sock => |path| try std.fmt.allocPrint(a, "--sock {s}", .{path}), 89 .sock => |path| try std.fmt.allocPrint(a, "--sock {s}", .{path}),
78 .quic => |q| try std.fmt.allocPrint(a, "quic://{s}", .{q.host_port}), 90 .quic => |q| try std.fmt.allocPrint(a, "quic://{s}", .{q.host_port}),
@@ -87,7 +99,6 @@ pub const Picker = struct {
87 break; 99 break;
88 }; 100 };
89 if (!found) try self.hosts.append(a, .{ .label = try a.dupe(u8, label), .target = try discovery.cloneTarget(a, current) }); 101 if (!found) try self.hosts.append(a, .{ .label = try a.dupe(u8, label), .target = try discovery.cloneTarget(a, current) });
90 return self;
91 } 102 }
92 pub fn deinit(self: *Picker) void { 103 pub fn deinit(self: *Picker) void {
93 if (self.job) |job| job.stop(); 104 if (self.job) |job| job.stop();
@@ -261,11 +272,19 @@ pub const Picker = struct {
261 } 272 }
262 } 273 }
263 fn validOrigin(self: *Picker) bool { 274 fn validOrigin(self: *Picker) bool {
264 const pending = self.rt.workspace.tab().pending orelse return false; 275 if (self.rt.workspace.active_tab_id != self.origin_tab) 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); 276 if (self.origin) |origin| {
277 if (!self.rt.accepts(origin)) return false;
278 } else if (self.rt.workspace.tab().tree.root != null) return false;
279 return self.mode == .replace or std.meta.eql(self.rt.workspace.tab().pending, self.pending);
266 } 280 }
267 fn preflight(self: *Picker) !model.Placement { 281 fn preflight(self: *Picker) !model.Placement {
268 if (!self.validOrigin()) return error.OriginChanged; 282 if (!self.validOrigin()) return error.OriginChanged;
283 if (self.mode == .replace) {
284 const layout = self.rt.workspace.layout(self.width, self.height, self.metrics);
285 if (!layout.fits) return error.TooSmall;
286 return layout.get(self.origin.?.pane) orelse error.MissingPane;
287 }
269 return self.rt.workspace.preview(self.width, self.height, self.metrics); 288 return self.rt.workspace.preview(self.width, self.height, self.metrics);
270 } 289 }
271 fn insert(self: *Picker, name: []const u8, created: bool) !void { 290 fn insert(self: *Picker, name: []const u8, created: bool) !void {
@@ -273,13 +292,22 @@ pub const Picker = struct {
273 self.insertionFailed(error.OriginChanged, created); 292 self.insertionFailed(error.OriginChanged, created);
274 return; 293 return;
275 } 294 }
276 _ = self.rt.addWithPolicy(self.hosts.items[self.host_index].target orelse { 295 const target = self.hosts.items[self.host_index].target orelse {
277 self.setNotice("Target is unavailable"); 296 self.setNotice("Target is unavailable");
278 return; 297 return;
279 }, name, self.width, self.height, self.metrics, true) catch |err| {
280 self.insertionFailed(err, created);
281 return;
282 }; 298 };
299 if (self.mode == .replace) {
300 const layout = self.rt.workspace.layout(self.width, self.height, self.metrics);
301 self.rt.replace(self.origin.?.pane, target, name, layout.get(self.origin.?.pane).?, false) catch |err| {
302 self.insertionFailed(err, created);
303 return;
304 };
305 } else {
306 _ = self.rt.addWithPolicy(target, name, self.width, self.height, self.metrics, true) catch |err| {
307 self.insertionFailed(err, created);
308 return;
309 };
310 }
283 self.inserted = true; 311 self.inserted = true;
284 self.closed = true; 312 self.closed = true;
285 } 313 }
@@ -389,4 +417,12 @@ test "picker validates fit before create and preserves remote outcome after orig
389 try picker.applyResult(.{ .ticket = picker.ticket, .phase = .cancelled, .may_have_created = true }, true); 417 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); 418 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); 419 try std.testing.expectEqual(@as(usize, 1), rt.workspace.layout(800, 600, metrics).len);
420 rt.workspace.tab().pending = picker.pending;
421 const placement = rt.workspace.layout(800, 600, metrics).get(id).?;
422 const old_key = rt.get(id).?.key;
423 try rt.retry(id, placement);
424 try std.testing.expect(!rt.accepts(old_key));
425 try picker.applyResult(.{ .ticket = picker.ticket, .phase = .created, .may_have_created = true }, true);
426 try std.testing.expect(std.mem.indexOf(u8, picker.noticeText(), "Session created 'new-shell'") != null);
427 try std.testing.expectEqual(@as(usize, 1), rt.workspace.layout(800, 600, metrics).len);
392 } 428 }
src/gui/runtime.zig
Old New
@@ -17,15 +17,16 @@ pub const Live = struct {
17 notify: Notify, 17 notify: Notify,
18 pending: std.atomic.Value(bool) = .init(false), 18 pending: std.atomic.Value(bool) = .init(false),
19 bell_until: i64 = 0, 19 bell_until: i64 = 0,
20 preserve_snapshot: bool = false,
20 /// The pane owns the transport strings and must outlive this attachment. 21 /// The pane owns the transport strings and must outlive this attachment.
21 /// Allocate the wake context at its final address before starting its pump. 22 /// Allocate the wake context at its final address before starting its pump.
22 fn start(alloc: std.mem.Allocator, pane: *const model.Pane, size: term.protocol.Size, notify: Notify, existing_only: bool) !*Live { 23 fn start(alloc: std.mem.Allocator, pane: *const model.Pane, size: term.protocol.Size, notify: Notify, existing_only: bool, retry_initial: bool) !*Live {
23 const self = try alloc.create(Live); 24 const self = try alloc.create(Live);
24 errdefer alloc.destroy(self); 25 errdefer alloc.destroy(self);
25 const grid = try term.grid.Grid.init(alloc, 1, 1); 26 const grid = try term.grid.Grid.init(alloc, 1, 1);
26 errdefer grid.deinit(); 27 errdefer grid.deinit();
27 self.* = .{ .key = .{ .pane = pane.id, .generation = pane.generation }, .pump = undefined, .snapshot = grid, .size = size, .notify = notify }; 28 self.* = .{ .key = .{ .pane = pane.id, .generation = pane.generation }, .pump = undefined, .snapshot = grid, .size = size, .notify = notify };
28 self.pump = try Pump.start(alloc, .{ .target = pane.identity.target, .session = pane.identity.session, .cols = size.cols, .rows = size.rows, .existing_only = existing_only, .wake = wake, .wake_ctx = self }); 29 self.pump = try Pump.start(alloc, .{ .target = pane.identity.target, .session = pane.identity.session, .cols = size.cols, .rows = size.rows, .existing_only = existing_only, .retry_initial = retry_initial, .wake = wake, .wake_ctx = self });
29 return self; 30 return self;
30 } 31 }
31 fn destroy(self: *Live, alloc: std.mem.Allocator) void { 32 fn destroy(self: *Live, alloc: std.mem.Allocator) void {
@@ -42,6 +43,8 @@ pub const Live = struct {
42 pub fn capture(self: *Live, cols: u16, rows: u16) !u32 { 43 pub fn capture(self: *Live, cols: u16, rows: u16) !u32 {
43 self.pump.mu.lock(); 44 self.pump.mu.lock();
44 defer self.pump.mu.unlock(); 45 defer self.pump.mu.unlock();
46 if (self.preserve_snapshot and !self.pump.snapshot_ready) return 0;
47 self.preserve_snapshot = false;
45 const src = self.pump.grid; 48 const src = self.pump.grid;
46 try copyGrid(self.snapshot, src, @min(cols, src.cols), @min(rows, src.rows)); 49 try copyGrid(self.snapshot, src, @min(cols, src.cols), @min(rows, src.rows));
47 self.snapshot_seq = self.pump.replica.last_seq; 50 self.snapshot_seq = self.pump.replica.last_seq;
@@ -93,13 +96,52 @@ pub const Runtime = struct {
93 pub fn addWithPolicy(self: *Runtime, target: client.Target, session: []const u8, width: u32, height: u32, metrics: model.Metrics, existing_only: bool) !model.PaneId { 96 pub fn addWithPolicy(self: *Runtime, target: client.Target, session: []const u8, width: u32, height: u32, metrics: model.Metrics, existing_only: bool) !model.PaneId {
94 var prepared = try self.workspace.prepare(target, session, width, height, metrics); 97 var prepared = try self.workspace.prepare(target, session, width, height, metrics);
95 errdefer prepared.discard(self.alloc); 98 errdefer prepared.discard(self.alloc);
96 const live = try Live.start(self.alloc, prepared.pane, .{ .cols = prepared.placement.cols, .rows = prepared.placement.rows }, self.notify, existing_only); 99 const live = try self.attach(prepared.pane, prepared.placement, existing_only, false);
100 self.workspace.commit(prepared);
101 return live.key.pane;
102 }
103 /// Restore starts independent join-only pumps for the already decoded tree.
104 pub fn restore(self: *Runtime, layout: *const model.Layout) !void {
105 for (layout.items()) |placement| {
106 const pane = self.workspace.pane(placement.id).?;
107 _ = try self.attach(pane, placement, true, true);
108 }
109 }
110 fn attach(self: *Runtime, pane: *const model.Pane, placement: model.Placement, existing_only: bool, retry_initial: bool) !*Live {
97 for (&self.lives) |*slot| if (slot.* == null) { 111 for (&self.lives) |*slot| if (slot.* == null) {
112 const live = try Live.start(self.alloc, pane, .{ .cols = placement.cols, .rows = placement.rows }, self.notify, existing_only, retry_initial);
113 slot.* = live;
114 return live;
115 };
116 return error.WorkspaceFull;
117 }
118 /// All fallible construction precedes replacement. The old pump joins
119 /// before its identity is freed; queued notifications retain the old key.
120 pub fn replace(self: *Runtime, id: model.PaneId, target: client.Target, session: []const u8, placement: model.Placement, keep_snapshot: bool) !void {
121 const pane = self.workspace.pane(id) orelse return error.MissingPane;
122 if (pane.generation == std.math.maxInt(u64)) return error.IdExhausted;
123 var identity = try model.Identity.init(self.alloc, target, session);
124 errdefer identity.deinit();
125 const next: model.Pane = .{ .id = id, .generation = pane.generation + 1, .identity = identity };
126 const live = try Live.start(self.alloc, &next, .{ .cols = placement.cols, .rows = placement.rows }, self.notify, true, true);
127 errdefer live.destroy(self.alloc);
128 const old = self.get(id) orelse return error.MissingPane;
129 if (keep_snapshot) {
130 try copyGrid(live.snapshot, old.snapshot, old.snapshot.cols, old.snapshot.rows);
131 live.snapshot_seq = old.snapshot_seq;
132 live.preserve_snapshot = true;
133 }
134 for (&self.lives) |*slot| if (slot.* == old) {
135 old.destroy(self.alloc);
136 pane.identity.deinit();
137 pane.* = next;
98 slot.* = live; 138 slot.* = live;
99 break; 139 break;
100 }; 140 };
101 self.workspace.commit(prepared); 141 }
102 return live.key.pane; 142 pub fn retry(self: *Runtime, id: model.PaneId, placement: model.Placement) !void {
143 const pane = self.workspace.pane(id) orelse return error.MissingPane;
144 try self.replace(id, pane.identity.target, pane.identity.session, placement, true);
103 } 145 }
104 pub fn remove(self: *Runtime, id: model.PaneId) void { 146 pub fn remove(self: *Runtime, id: model.PaneId) void {
105 for (&self.lives) |*slot| if (slot.*) |live| { 147 for (&self.lives) |*slot| if (slot.*) |live| {
@@ -133,7 +175,7 @@ pub const Runtime = struct {
133 for (self.lives) |p| if (p) |live| { 175 for (self.lives) |p| if (p) |live| {
134 changed = live.pending.swap(false, .acq_rel) or changed; 176 changed = live.pending.swap(false, .acq_rel) or changed;
135 const status = live.pump.state(); 177 const status = live.pump.state();
136 if (status.phase != live.status.phase or status.exit_code != live.status.exit_code) changed = true; 178 if (status.phase != live.status.phase or status.exit_code != live.status.exit_code or status.ending.phase != live.status.ending.phase or status.ending.request != live.status.ending.request) changed = true;
137 live.status = status; 179 live.status = status;
138 if (status.bell) { 180 if (status.bell) {
139 live.bell_until = now + 200; 181 live.bell_until = now + 200;
@@ -165,3 +207,63 @@ test "frozen pane grids own text and survive live mutation and shrink" {
165 try std.testing.expectEqual(@as(u16, 1), dst.rows); 207 try std.testing.expectEqual(@as(u16, 1), dst.rows);
166 try std.testing.expectEqual(@as(usize, 0), dst.row(0).text.items.len); 208 try std.testing.expectEqual(@as(usize, 0), dst.row(0).text.items.len);
167 } 209 }
210
211 fn waitPhase(live: *Live, phase: client.session_pump.Phase) !void {
212 const until = std.time.milliTimestamp() + 2000;
213 while (std.time.milliTimestamp() < until) {
214 if (live.pump.state().phase == phase) return;
215 std.Thread.sleep(std.time.ns_per_ms);
216 }
217 return error.PhaseTimeout;
218 }
219
220 test "same-pane replacement is transactional and retry keeps text through an invalid first snapshot" {
221 const a = std.testing.allocator;
222 var tmp = std.testing.tmpDir(.{});
223 defer tmp.cleanup();
224 const path = try tmp.dir.realpathAlloc(a, ".");
225 defer a.free(path);
226 const command = try std.fmt.allocPrint(a, "cat {s}/snapshot -", .{path});
227 defer a.free(command);
228 var snapshot: [34]u8 = @splat(0);
229 term.protocol.writeSnapshotPrefix(snapshot[0..term.protocol.snapshot_prefix_len], .{ .seq = 37, .history_rows = 0, .cols = 11, .rows = 3, .epoch = 93 });
230 term.protocol.writeSnapshotCursor(snapshot[term.protocol.snapshot_prefix_len..][0..term.protocol.snapshot_cursor_len], 0, 0);
231 var bytes: std.ArrayList(u8) = .empty;
232 defer bytes.deinit(a);
233 try term.protocol.appendFrame(&bytes, a, .snapshot, snapshot[0..28]);
234 try tmp.dir.writeFile(.{ .sub_path = "snapshot", .data = bytes.items });
235 var rt = Runtime.init(a, .{});
236 defer rt.deinit();
237 const m: model.Metrics = .{ .cell_w = 10, .cell_h = 20 };
238 const id = try rt.add(.{ .via = command }, "kept", 800, 600, m);
239 const old = rt.get(id).?;
240 try waitPhase(old, .failed);
241 try old.snapshot.lines[0].text.appendSlice(a, "OLD");
242 old.snapshot.lines[0].cells[0] = .{ .text_len = 3 };
243 const before = rt.workspace.layout(800, 600, m);
244 const key = old.key;
245 var failing = std.testing.FailingAllocator.init(a, .{ .fail_index = 0 });
246 rt.alloc = failing.allocator();
247 try std.testing.expectError(error.OutOfMemory, rt.retry(id, before.get(id).?));
248 rt.alloc = a;
249 try std.testing.expect(rt.get(id).? == old and rt.accepts(key));
250 try std.testing.expectEqualStrings("kept", rt.workspace.pane(id).?.identity.session);
251 try rt.retry(id, before.get(id).?);
252 const replacement = rt.get(id).?;
253 try waitPhase(replacement, .failed);
254 try std.testing.expectEqual(key.generation + 1, replacement.key.generation);
255 try std.testing.expect(!rt.accepts(key));
256 try std.testing.expectEqual(id, rt.workspace.tab().focus.?);
257 try std.testing.expectEqualDeep(before.items(), rt.workspace.layout(800, 600, m).items());
258 _ = try replacement.capture(11, 3);
259 try std.testing.expectEqualStrings("OLD", replacement.snapshot.row(0).textOf(replacement.snapshot.row(0).cells[0]));
260 // A later retry with a fully valid snapshot finally replaces the cache.
261 bytes.clearRetainingCapacity();
262 try term.protocol.appendFrame(&bytes, a, .snapshot, &snapshot);
263 try tmp.dir.writeFile(.{ .sub_path = "snapshot", .data = bytes.items });
264 try rt.retry(id, before.get(id).?);
265 try waitPhase(rt.get(id).?, .attached);
266 _ = try rt.get(id).?.capture(11, 3);
267 try std.testing.expectEqual(@as(u64, 37), rt.get(id).?.snapshot_seq);
268 try std.testing.expectEqual(@as(usize, 0), rt.get(id).?.snapshot.row(0).text.items.len);
269 }
src/gui/workspace.zig
Old New
@@ -155,6 +155,7 @@ const Tree = struct {
155 self.root = at; 155 self.root = at;
156 return; 156 return;
157 } 157 }
158 if (self.next_divider_id == std.math.maxInt(u64)) return error.IdExhausted;
158 const at = self.leaf(origin orelse return error.MissingPane) orelse return error.MissingPane; 159 const at = self.leaf(origin orelse return error.MissingPane) orelse return error.MissingPane;
159 const a = try self.free(); 160 const a = try self.free();
160 self.nodes[a] = self.nodes[at]; 161 self.nodes[a] = self.nodes[at];
@@ -356,6 +357,7 @@ pub const Workspace = struct {
356 return .{ .pane = p, .tree = plan.tree, .placement = plan.placement }; 357 return .{ .pane = p, .tree = plan.tree, .placement = plan.placement };
357 } 358 }
358 fn insertionPlan(self: *Workspace, width: u32, height: u32, m: Metrics) !struct { tree: Tree, placement: Placement } { 359 fn insertionPlan(self: *Workspace, width: u32, height: u32, m: Metrics) !struct { tree: Tree, placement: Placement } {
360 if (self.next_pane_id == std.math.maxInt(u64)) return error.IdExhausted;
359 const t = self.tab(); 361 const t = self.tab();
360 var count: usize = 0; 362 var count: usize = 0;
361 for (t.panes) |p| { 363 for (t.panes) |p| {
test/native_lifecycle.py
Old New
@@ -0,0 +1,511 @@
1 #!/usr/bin/env python3
2 """Native lifecycle and persistence checks using real sessions and passive oracles."""
3 import fcntl
4 import json
5 import os
6 from pathlib import Path
7 import shlex
8 import signal
9 import struct
10 import subprocess
11 import sys
12 import termios
13 import time
14
15 sys.dont_write_bytecode = True
16 from native_tiling import colour_counts, eventually, require
17 from native_resize import ResizeRig, by_id, edge
18 from native_picker import create, end, sessions
19
20
21 class LifecycleRig(ResizeRig):
22 def __init__(self, mux, muxg):
23 super().__init__(mux, muxg)
24 self.saved = Path(self.env['XDG_STATE_HOME']) / 'mux/native-workspace.json'
25 self.cli_layout = self.saved.parent / 'layout'
26 self.saved.parent.mkdir(exist_ok=True)
27 self.cli_bytes = b'CLI-LAYOUT-UNTOUCHED\n'
28 self.cli_layout.write_bytes(self.cli_bytes)
29 self.gui_cwd = None
30
31 def spawn(self, argv, label, env=None, cwd=None):
32 if cwd is None:
33 return super().spawn(argv, label, env)
34 log = (self.root / (label + '.log')).open('wb')
35 self.logs.append(log)
36 proc = subprocess.Popen(argv, env=self.env if env is None else env,
37 cwd=cwd, stdout=log, stderr=subprocess.STDOUT)
38 self.procs.append(proc)
39 return proc
40
41 def failure_artifacts(self):
42 print('Native lifecycle failure artifacts:', self.root, file=sys.stderr)
43 for path in self.root.glob('gui*.log'):
44 print(path.name + ':\n' + path.read_text()[-3000:], file=sys.stderr)
45
46 def launch_gui(self, args, label, attached=True):
47 # An empty persistent workspace has no provisional pane. Rig's original
48 # launch predicate assumes one pane, so only process setup is repeated.
49 require(self.gui is None, 'close the previous fixture window before relaunch')
50 fifo = self.root / (label + '.fifo')
51 os.mkfifo(fifo)
52 self.fd = os.open(fifo, os.O_RDWR | os.O_NONBLOCK)
53 self.env['MUXG_TEST_FIFO'] = str(fifo)
54 self.gui_log = self.root / (label + '.log')
55 self.gui = self.spawn([self.muxg, *args], label, cwd=self.gui_cwd)
56 state = self.state()
57 if self.env['SDL_VIDEO_DRIVER'] == 'wayland' and os.environ.get('MUXG_TEST_SWAY_FLOAT') == '1':
58 command = (f'[pid={self.gui.pid}] floating enable, border none, '
59 'resize set width 960 px height 600 px')
60 result = subprocess.run(['swaymsg', '-r', command], env=self.env,
61 capture_output=True, text=True, check=True, timeout=3)
62 require(all(item['success'] for item in json.loads(result.stdout)), 'could not float test window')
63 state = self.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (960, 600))
64 if attached:
65 state = self.wait_state(lambda s: bool(s['panes']) and
66 all(p['phase'] == 'attached' for p in s['panes']))
67 return state
68
69 def assert_cli_untouched(self):
70 require(self.cli_layout.read_bytes() == self.cli_bytes,
71 'native workspace changed terminal mux layout state')
72
73 def menu(self, label):
74 state = self.wait_state(lambda s: s.get('recovery') and
75 any(row['label'] == label for row in s['recovery']['rows']))
76 menu = state['recovery']
77 index = next(i for i, row in enumerate(menu['rows']) if row['label'] == label)
78 for _ in range(abs(index - menu['selected'])):
79 self.key('down' if index > menu['selected'] else 'up')
80 self.key('enter')
81
82 def recover(self, label):
83 self.chord('p')
84 self.menu(label)
85
86 def holder(self, sock, name):
87 label = 'holder-' + str(time.time_ns())
88 log = (self.root / (label + '.log')).open('wb')
89 self.logs.append(log)
90 proc = subprocess.Popen([self.mux, '--sock', sock, '--session', name],
91 env=self.env, stdin=subprocess.PIPE, stdout=log, stderr=subprocess.STDOUT)
92 self.procs.append(proc)
93 proc.stdin.write(b"printf '\\033[2J\\033[HHOLDER-%s\\n' ONLINE\n")
94 proc.stdin.flush()
95 self.wait_marker(sock, name, 'HOLDER-ONLINE')
96 return proc
97
98 def release_holder(self, proc):
99 proc.stdin.write(b'\x1c\x1c')
100 proc.stdin.flush()
101 require(proc.wait(timeout=3) == 0, 'real terminal client failed to detach')
102 proc.stdin.close()
103
104 def saved_json(self):
105 return json.loads(self.saved.read_text())
106
107 def wait_saved(self, previous=None):
108 def probe():
109 if not self.saved.exists():
110 return None
111 raw = self.saved.read_bytes()
112 if raw == previous:
113 return None
114 return json.loads(raw)
115 return eventually(probe, 'committed native workspace did not save')
116
117 def mark(self, pane_id, sock, session, marker):
118 self.focus(pane_id)
119 # The complete marker occurs only in command output, not terminal echo.
120 left, right = marker.rsplit('-', 1)
121 self.shell("printf '\\033[?25l\\033[2J\\033[H\\033[38;2;0;255;0m%s-%s\\033[0m\\n' " +
122 shlex.quote(left) + ' ' + shlex.quote(right))
123 self.wait_marker(sock, session, marker)
124 state = self.wait_state(lambda s: marker in by_id(s)[pane_id]['painted_text'])
125 eventually(lambda: colour_counts(self.last_pixels(), by_id(state)[pane_id]['content'], 'green')[0] > 20,
126 'terminal marker did not produce retained framebuffer pixels')
127
128 def remember_tty(self, pane_id):
129 self.focus(pane_id)
130 path = self.root / f'tty-{pane_id}-{time.time_ns()}'
131 self.shell('tty > ' + shlex.quote(str(path)))
132 eventually(lambda: path.exists() and path.stat().st_size > 0, 'tty path did not arrive')
133 self.tty_paths[pane_id] = path.read_text().strip()
134
135 def kernel_sizes(self):
136 state = self.state()
137 for pane in state['panes']:
138 expected = (pane['content']['h'] // state['cell_h'],
139 pane['content']['w'] // state['cell_w'])
140 require(expected == (pane['rows'], pane['cols']), 'pane cell claim disagrees with geometry')
141 actual = None
142 def probe():
143 nonlocal actual
144 fd = os.open(self.tty_paths[pane['id']], os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
145 try:
146 actual = struct.unpack('HHHH', fcntl.ioctl(fd, termios.TIOCGWINSZ, bytes(8)))[:2]
147 return actual == expected
148 finally:
149 os.close(fd)
150 try:
151 eventually(probe, f"pane {pane['id']} kernel dimensions differ from {expected}")
152 except AssertionError as error:
153 raise AssertionError(f'{error}; observed {actual}') from error
154
155
156 def identity(state):
157 return (state['tab'], state['focus'],
158 [(p['id'], p['label']) for p in state['panes']],
159 [(d['id'], d['direction']) for d in state['dividers']])
160
161
162 def durable_structure(document):
163 # Focus is deliberately omitted: exercising the usable neighbour commits
164 # a legitimate focus change while an independent host is unavailable.
165 return (document['version'], document['active_tab_id'], document['next_pane_id'],
166 [(tab['id'], tab['tree'], tab['panes']) for tab in document['tabs']])
167
168
169 def start_persistent(rig):
170 a, _ = rig.daemon('lifecycle-left')
171 b, _ = rig.daemon('lifecycle-right')
172 rig.catalogue(['--sock ' + a, '--sock ' + b])
173 rig.launch_gui([], 'gui-persistent-first', attached=False)
174 rig.picker('hosts')
175 require(not rig.state()['panes'], 'first launch created a shell before explicit selection')
176 rig.choose('--sock ' + a)
177 rig.picker('sessions')
178 rig.new_session('left')
179 rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached')
180 rig.host('--sock ' + b, 'v')
181 rig.new_session('right')
182 rig.wait_state(lambda s: len(s['panes']) == 2 and all(p['phase'] == 'attached' for p in s['panes']))
183 rig.host('--sock ' + a, 'b')
184 rig.choose('0')
185 state = rig.wait_state(lambda s: len(s['panes']) == 3 and
186 all(p['phase'] == 'attached' for p in s['panes']))
187 refs = {state['panes'][0]['id']: (a, 'left'), state['panes'][1]['id']: (b, 'right'),
188 state['panes'][2]['id']: (a, '0')}
189 for pane_id, (sock, name) in refs.items():
190 rig.remember_tty(pane_id)
191 rig.mark(pane_id, sock, name, 'PERSIST-' + name)
192 rig.drag('beside', dx=state['cell_w'] * 7)
193 rig.drag('stacked', dy=-state['cell_h'] * 4)
194 rig.focus(state['panes'][1]['id'])
195 rig.kernel_sizes()
196 rig.wait_saved()
197 rig.assert_cli_untouched()
198 return refs
199
200
201 def single_writer(rig):
202 env = rig.env.copy()
203 env.pop('MUXG_TEST_FIFO', None)
204 second = rig.spawn([rig.muxg], 'gui-second-writer', env)
205 require(second.wait(timeout=3) != 0, 'second default workspace writer was accepted')
206 diagnostic = (rig.root / 'gui-second-writer.log').read_text().lower()
207 require('already' in diagnostic and 'open' in diagnostic, 'writer refusal lacked an actionable reason')
208 require(rig.gui.poll() is None, 'second writer disturbed the first window')
209 rig.ok('a second persistent writer is refused while the first remains usable')
210
211
212 def save_failure(rig):
213 before = rig.saved.read_bytes()
214 mode = rig.saved.parent.stat().st_mode & 0o777
215 rig.saved.parent.chmod(0o555)
216 try:
217 require(not os.access(rig.saved.parent, os.W_OK), 'test UID can bypass the save-failure fixture')
218 state = rig.state()
219 rig.drag('beside', dx=state['cell_w'] * -3)
220 rig.wait_state(lambda s: abs(edge(s, 'beside') - edge(state, 'beside')) >= state['cell_w'] * 2)
221 rig.wait_state(lambda s: 'sav' in s.get('notice', '').lower() and
222 any(word in s['notice'].lower() for word in ('fail', 'error', 'cannot', 'denied')))
223 require(rig.saved.read_bytes() == before, 'failed save damaged the previous workspace bytes')
224 rig.kernel_sizes()
225 finally:
226 rig.saved.parent.chmod(mode)
227 rig.drag('beside', dx=rig.state()['cell_w'])
228 rig.wait_saved(before)
229 rig.assert_cli_untouched()
230 rig.ok('real filesystem save failure preserves prior bytes and reports the error without blocking panes')
231
232
233 def temporary_and_roundtrip(rig, refs):
234 before = rig.state()
235 tree = rig.saved_json()['tabs'][0]['tree']
236 rig.send('resize:8x8')
237 rig.wait_state(lambda s: (s['logical_width'], s['logical_height']) == (8, 8))
238 rig.kernel_sizes()
239 rig.quit()
240 saved = rig.saved.read_bytes()
241 document = json.loads(saved)
242 require(document['tabs'][0]['tree'] == tree, 'saving a clamped window rewrote relative split weights')
243 for sock, name in refs.values():
244 require(name in sessions(sock), 'window close ended a saved daemon session')
245 # A saved target must remain usable after being removed from discovery.
246 rig.catalogue([])
247 sock, name = next(iter(refs.values()))
248 rig.launch_gui(['--sock', sock, '--session', name], 'gui-explicit-temporary')
249 rig.send('resize:740x440')
250 rig.wait_state(lambda s: s['logical_width'] == 740)
251 rig.quit()
252 require(rig.saved.read_bytes() == saved, 'explicit target overwrote the default workspace')
253 if os.environ.get('MUXG_LIFECYCLE_RESTORE_WAYLAND') == '1':
254 require(os.environ.get('MUXG_TEST_SWAY_FLOAT') == '1', 'density restore needs its own floating fixture')
255 rig.env['SDL_VIDEO_DRIVER'] = 'wayland'
256 rig.launch_gui([], 'gui-restored')
257 restored = rig.state()
258 if os.environ.get('MUXG_LIFECYCLE_RESTORE_WAYLAND') == '1':
259 require(before['width'] == before['logical_width'] and
260 restored['width'] == restored['logical_width'] * 2,
261 'restore did not transition from 100% offscreen to actual 200% Wayland')
262 require(identity(restored) == identity(before), 'restore lost stable IDs, targets, focus or split ancestry')
263 require(rig.saved_json() == document, 'restore rewrote persisted split weights or identities')
264 rig.kernel_sizes()
265 for pane_id, (sock, name) in refs.items():
266 # Tiny PTYs may legitimately reflow old markers into history. Compare
267 # the actual current daemon grid before sending any new terminal input.
268 def normalized(text):
269 return '\n'.join(line.rstrip() for line in text.splitlines()).rstrip('\n')
270 expected = normalized(rig.dump(sock, name))
271 rig.wait_state(lambda s: by_id(s)[pane_id]['painted_seq'] > 0 and
272 normalized(by_id(s)[pane_id]['painted_text']) == expected)
273 rig.mark(pane_id, sock, name, 'RESTORED-' + name)
274 rig.assert_cli_untouched()
275 rig.ok('close/reopen restores nested targets, identity, focus, weights and PTYs independently of the catalogue')
276 rig.ok('explicit target remains temporary and neither mode writes terminal layout state')
277
278
279 def preservation_case(mux, muxg, label, original, unreadable=False):
280 rig = LifecycleRig(mux, muxg)
281 try:
282 sock, _ = rig.daemon(label + '-recovery')
283 rig.catalogue(['--sock ' + sock])
284 rig.saved.write_bytes(original)
285 if unreadable:
286 rig.saved.chmod(0)
287 require(not os.access(rig.saved, os.R_OK), 'test UID can bypass unreadable-file fixture')
288 rig.launch_gui([], 'gui-' + label, attached=False)
289 rig.wait_state(lambda s: 'sav' in s.get('notice', '').lower() or
290 'preserv' in s.get('notice', '').lower())
291 # Even after permissions recover, this run must remain sealed against
292 # replacing the original file with the fallback workspace.
293 rig.saved.chmod(0o600)
294 rig.picker('hosts')
295 rig.choose('--sock ' + sock)
296 rig.picker('sessions')
297 rig.new_session('unsaved')
298 state = rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached')
299 rig.mark(state['panes'][0]['id'], sock, 'unsaved', label.upper() + '-USABLE')
300 rig.quit()
301 require(rig.saved.read_bytes() == original, label + ' state was overwritten')
302 require('unsaved' in sessions(sock), 'preservation fallback ended its live session on close')
303 rig.assert_cli_untouched()
304 print(f'lifecycle OK: {label} original bytes preserved;', rig.root, flush=True)
305 except BaseException:
306 rig.failure_artifacts()
307 raise
308 finally:
309 if rig.saved.exists():
310 rig.saved.chmod(0o600)
311 rig.close()
312
313
314 def relative_target_reopen(mux, muxg):
315 rig = LifecycleRig(mux, muxg)
316 try:
317 sock, _ = rig.daemon('relative')
318 rig.gui_cwd = rig.root
319 rig.catalogue(['--sock relative.sock'])
320 rig.launch_gui([], 'gui-relative-original', attached=False)
321 rig.picker('hosts')
322 rig.choose('--sock relative.sock')
323 rig.picker('sessions')
324 rig.new_session('relative-kept')
325 state = rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached')
326 pane_id = state['panes'][0]['id']
327 rig.mark(pane_id, sock, 'relative-kept', 'RELATIVE-ORIGINAL')
328 rig.quit()
329 saved = rig.saved_json()
330 require(saved['tabs'][0]['panes'][0]['target'] == {'sock': sock},
331 'saved relative transport was not anchored to its original working directory')
332 rig.gui_cwd = rig.root / 'different-working-directory'
333 rig.gui_cwd.mkdir()
334 rig.catalogue([])
335 rig.launch_gui([], 'gui-relative-restored')
336 require(rig.state()['panes'][0]['id'] == pane_id, 'relative target restore replaced its pane ID')
337 rig.mark(pane_id, sock, 'relative-kept', 'DIFFERENT-CWD')
338 rig.quit()
339 rig.assert_cli_untouched()
340 require('relative-kept' in sessions(sock), 'relative target session did not survive reopen')
341 print('lifecycle OK: relative socket survives actual changed-cwd reopen;', rig.root, flush=True)
342 except BaseException:
343 rig.failure_artifacts()
344 raise
345 finally:
346 rig.close()
347
348
349 def lifecycle_actions(rig, refs):
350 left, right, lower = list(refs)
351 right_sock, right_name = refs[right]
352 rig.focus(right)
353 holder = rig.holder(right_sock, right_name)
354 rig.chord('x')
355 state = rig.wait_state(lambda s: s.get('recovery') and
356 any(row['label'] == 'End for all clients' for row in s['recovery']['rows']))
357 require(state['recovery']['rows'][state['recovery']['selected']]['label'] == 'Cancel',
358 'shared-session force confirmation did not default to Cancel')
359 require(right in by_id(state) and right_name in sessions(right_sock),
360 'daemon End refusal removed the pane or shared session')
361 rig.key('escape')
362 require(holder.poll() is None, 'refused End disconnected another terminal client')
363 rig.release_holder(holder)
364 rig.chord('x')
365 rig.wait_state(lambda s: right not in by_id(s))
366 eventually(lambda: right_name not in sessions(right_sock), 'accepted End left its daemon session live')
367 rig.ok('End respects an actual second terminal client and succeeds when the GUI is the sole attachment')
368
369 rig.focus(left)
370 rig.chord('d')
371 state = rig.wait_state(lambda s: len(s['panes']) == 1 and left not in by_id(s))
372 sock, name = refs[left]
373 require(name in sessions(sock), 'Detach terminated its daemon session')
374 require(state['panes'][0]['outer'] == {'x': 0, 'y': 0, 'w': state['width'], 'h': state['height']},
375 'remaining sibling did not fill the detached pane area')
376 rig.ok('Detach removes only its leaf and retains the independently observed daemon session')
377
378 rig.shell('exit 7')
379 state = rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'exited')
380 require(state['panes'][0]['id'] == lower and state['panes'][0]['exit_code'] == 7,
381 'ordinary exit lost its stable pane or exit status')
382 require(rig.gui.poll() is None, 'a sole exited pane closed the GUI')
383 generation = state['panes'][0]['generation']
384 rig.recover('Choose session')
385 rig.picker('hosts')
386 rig.choose('--sock ' + sock)
387 rig.picker('sessions')
388 rig.choose(name)
389 state = rig.wait_state(lambda s: len(s['panes']) == 1 and s['panes'][0]['phase'] == 'attached')
390 require(state['panes'][0]['id'] == lower and state['panes'][0]['generation'] > generation,
391 'Choose session failed to replace the exited attachment in place')
392 rig.mark(lower, sock, name, 'RECOVERY-EXISTING')
393 rig.ok('ordinary shell exit stays visible and Choose session replaces its attachment in place')
394
395 holder = rig.holder(sock, name)
396 rig.chord('x')
397 rig.menu('End for all clients')
398 rig.wait_state(lambda s: not s['panes'])
399 eventually(lambda: name not in sessions(sock), 'explicit shared End left the session live')
400 holder.wait(timeout=3)
401 holder.stdin.close()
402 require(rig.gui.poll() is None, 'ending the last pane closed the empty workspace')
403 rig.chord('enter')
404 rig.picker('hosts')
405 rig.assert_cli_untouched()
406 rig.ok('explicit force ends the shared session; an empty tab remains available for adding a pane')
407
408
409 def recovery_workflow(mux, muxg):
410 rig = LifecycleRig(mux, muxg)
411 stopped = None
412 try:
413 refs = start_persistent(rig)
414 left, right, lower = list(refs)
415 a, missing = refs[lower]
416 b, right_name = refs[right]
417 rig.quit()
418 before = rig.saved.read_bytes()
419 end(a, missing)
420 create(a, 'replacement')
421 stopped = next(proc for sock, proc in rig.daemons if sock == b)
422 os.kill(stopped.pid, signal.SIGSTOP)
423 rig.catalogue([])
424 rig.launch_gui([], 'gui-restore-unavailable', attached=False)
425 rig.wait_state(lambda s: len(s['panes']) == 3 and by_id(s)[left]['phase'] == 'attached')
426 rig.mark(left, *refs[left], 'RESTORE-INDEPENDENT')
427 require(missing not in sessions(a), 'restore silently recreated a missing saved session')
428 started = time.monotonic()
429 rig.quit()
430 require(time.monotonic() - started < 3, 'shutdown waited for an unavailable restore attachment')
431 require(durable_structure(rig.saved_json()) == durable_structure(json.loads(before)),
432 'unavailable restore rewrote saved target identities')
433 rig.ok('an unavailable restore does not block another pane or bounded window shutdown')
434
435 rig.launch_gui([], 'gui-recovery-in-place', attached=False)
436 rig.wait_state(lambda s: len(s['panes']) == 3 and by_id(s)[left]['phase'] == 'attached')
437 rig.focus(lower)
438 rig.recover('Retry')
439 rig.wait_state(lambda s: by_id(s)[lower]['phase'] in ('failed', 'refused', 'exited', 'dial_failed'))
440 require(missing not in sessions(a), 'Retry recreated the missing saved session')
441 rig.focus(right)
442 state = rig.state()
443 generation = by_id(state)[right]['generation']
444 rig.recover('Choose session')
445 # Discovery was deliberately cleared; add only an explicit recovery
446 # target. Restoration itself already demonstrated catalogue independence.
447 rig.choose('Add host...')
448 rig.send('text:--sock ' + a, 'key:enter')
449 rig.picker('sessions')
450 rig.choose('replacement')
451 state = rig.wait_state(lambda s: by_id(s)[right]['phase'] == 'attached')
452 require(by_id(state)[right]['generation'] > generation, 'retarget reused the cancelled attachment generation')
453 new_generation = by_id(state)[right]['generation']
454 rig.mark(right, a, 'replacement', 'RETARGET-CURRENT')
455 os.kill(stopped.pid, signal.SIGCONT)
456 stopped = None
457 require(right_name in sessions(b), 'cancelling the old attachment terminated its daemon session')
458 # Delayed old transport activity must not replace the committed target.
459 deadline = time.monotonic() + .3
460 while time.monotonic() < deadline:
461 state = rig.state()
462 pane = by_id(state)[right]
463 require(pane['generation'] == new_generation and pane['label'].endswith('#replacement'),
464 'a cancelled attachment changed its replacement pane')
465 rig.focus(right)
466 rig.recover('Choose session')
467 rig.choose('Add host...')
468 rig.send('text:--sock ' + b, 'key:enter')
469 rig.picker('sessions')
470 rig.choose(right_name)
471 rig.wait_state(lambda s: by_id(s)[right]['phase'] == 'attached')
472 rig.mark(right, b, right_name, 'RETURNED-HOST')
473 rig.assert_cli_untouched()
474 rig.ok('missing sessions stay missing; in-place recovery cancels old work and a returned host is usable')
475 rig.quit()
476 print('lifecycle recovery artifacts:', rig.root, flush=True)
477 except BaseException:
478 rig.failure_artifacts()
479 raise
480 finally:
481 if stopped is not None:
482 os.kill(stopped.pid, signal.SIGCONT)
483 rig.close()
484
485
486 def main():
487 require(len(sys.argv) == 3, 'usage: native_lifecycle.py MUX MUXG')
488 mux, muxg = sys.argv[1:]
489 rig = LifecycleRig(mux, muxg)
490 try:
491 refs = start_persistent(rig)
492 single_writer(rig)
493 save_failure(rig)
494 temporary_and_roundtrip(rig, refs)
495 lifecycle_actions(rig, refs)
496 rig.quit()
497 valid_bytes = rig.saved.read_bytes()
498 print(f'native lifecycle main OK ({rig.checkpoints} checkpoints); artifacts: {rig.root}', flush=True)
499 except BaseException:
500 rig.failure_artifacts()
501 raise
502 finally:
503 rig.close()
504 recovery_workflow(mux, muxg)
505 preservation_case(mux, muxg, 'malformed', b'{"version": 1, "tabs": [ BROKEN ORIGINAL\n')
506 preservation_case(mux, muxg, 'unreadable', valid_bytes, unreadable=True)
507 relative_target_reopen(mux, muxg)
508
509
510 if __name__ == '__main__':
511 main()