04437bf9
feat: restore native workspaces and manage pane lifecycle
a73x 2026-09-05 10:54
Commit message
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() | ||