1d43cd59
fix: preserve native command mode across delayed End replies
a73x 2026-09-05 13:11
Commit message
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -272,26 +272,31 @@ within the release review or should remain an explicit follow-up. | |||
| 272 | ### Actions for Sprint 5 | 272 | ### Actions for Sprint 5 |
| 273 | 273 | ||
| 274 | Owner: the sprint lead and implementation/review pair. Close each with evidence | 274 | Owner: the sprint lead and implementation/review pair. Close each with evidence |
| 275 | at the final milestone retrospective. | 275 | at the final milestone retrospective. Completed items are evidenced in the |
| 276 | Sprint 5 retrospective below and the delivery plan. | ||
| 276 | 277 | ||
| 277 | - [ ] Include `make native-stress` and a real Sway raw-output run in the final | 278 | - [x] Include `make native-stress` and a real Sway raw-output run in the final |
| 278 | release review; build both demo binaries together in an isolated ReleaseSafe | 279 | release review; build both demo binaries together in an isolated ReleaseSafe |
| 279 | prefix and report sampled input latency separately from frame timing. | 280 | prefix and report sampled input latency separately from frame timing. |
| 280 | - [ ] Obtain explicit Sprint 4 demo acceptance before beginning the release review. | 281 | - [x] Resolve the Sprint 4 acceptance gate before beginning the release review. |
| 282 | The user explicitly said “Let’s start sprint 5”, authorizing advancement. | ||
| 283 | This supersedes waiting at that gate; separate Sprint 4 demo approval and | ||
| 284 | hands-on feedback are not claimed. Lifecycle remains in the final demo. | ||
| 281 | - [ ] Run the next ergonomic trial from proposed defaults through implementation | 285 | - [ ] Run the next ergonomic trial from proposed defaults through implementation |
| 282 | and demo, apply any requested adjustments, and record whether feedback came | 286 | and demo, apply any requested adjustments, and record whether feedback came |
| 283 | from the recording or a hands-on trial, if available, separately from automation. | 287 | from the recording or a hands-on trial, if available, separately from automation. |
| 284 | - [ ] Start with a bounded review/cleanup of the accumulated native workflow, then | 288 | Trial recordings are shared; acceptance and any requested adjustments remain open. |
| 289 | - [x] Start with a bounded review/cleanup of the accumulated native workflow, then | ||
| 285 | retain a closing cleanup pass; avoid introducing tabs UI or terminal tiling changes. | 290 | retain a closing cleanup pass; avoid introducing tabs UI or terminal tiling changes. |
| 286 | - [ ] Review delayed destructive actions across focus, picker, resize, drag, | 291 | - [x] Review delayed destructive actions across focus, picker, resize, drag, |
| 287 | detach, and replacement transitions; keep confirmation target identity visible. | 292 | detach, and replacement transitions; keep confirmation target identity visible. |
| 288 | - [ ] Review the retained SSH EOF/authentication classification limit and record | 293 | - [x] Review the retained SSH EOF/authentication classification limit and record |
| 289 | the disposition without weakening bounded reconnect or join-only restoration. | 294 | the disposition without weakening bounded reconnect or join-only restoration. |
| 290 | - [ ] Run the complete multi-host workflow and final adversarial review, including | 295 | - [x] Run the complete multi-host workflow and final adversarial review, including |
| 291 | shared sessions, state ownership/failures, high DPI, and cleanup of owned fixtures. | 296 | shared sessions, state ownership/failures, high DPI, and cleanup of owned fixtures. |
| 292 | - [ ] Record final demo acceptance separately from automation and any hands-on | 297 | - [ ] Record final demo acceptance separately from automation and any hands-on |
| 293 | ergonomic feedback, update the spec's completion status, and commit the milestone. | 298 | ergonomic feedback, update the spec's completion status, and commit the milestone. |
| 294 | - [ ] Evaluate model economy for the sprint using available evidence: record the | 299 | - [x] Evaluate model economy for the sprint using available evidence: record the |
| 295 | bounded pair's task scope, review yield, and observed token/time cost, explicitly | 300 | bounded pair's task scope, review yield, and observed token/time cost, explicitly |
| 296 | noting unavailable measurements, then decide whether the current model defaults | 301 | noting unavailable measurements, then decide whether the current model defaults |
| 297 | remain economical for the next sprint. | 302 | remain economical for the next sprint. |
| @@ -329,3 +334,90 @@ before launching fixtures as intended. Adversarial review accepted the test | |||
| 329 | after adding its Makefile target to `.PHONY`. | 334 | after adding its Makefile target to `.PHONY`. |
| 330 | As a negative control, deliberately bypassing the build target and pairing the | 335 | As a negative control, deliberately bypassing the build target and pairing the |
| 331 | release GUI with the Debug daemon failed the new latency assertion at 878 ms. | 336 | release GUI with the Debug daemon failed the new latency assertion at 878 ms. |
| 337 | |||
| 338 | |||
| 339 | ## Native tiling — Sprint 5, 2026-09-05 | ||
| 340 | |||
| 341 | The user explicitly authorized the release review. Implementation, adversarial | ||
| 342 | review, closing cleanup assessment and final gates are complete. Final demo | ||
| 343 | acceptance remains pending; advancing from Sprint 4 is not recorded as a separate demo approval. | ||
| 344 | The final trial retains the existing keys and asks for feedback on target clarity, | ||
| 345 | Cancel-first confirmation, and returning to terminal input. Hands-on feedback is | ||
| 346 | optional and will be recorded separately from video approval. | ||
| 347 | |||
| 348 | ### What worked | ||
| 349 | |||
| 350 | - A bounded opening review found no justified attachment or persistence refactor. | ||
| 351 | The closing review reached the same conclusion after the targeted fix; no | ||
| 352 | cleanup quota or new abstraction was introduced. | ||
| 353 | - Adversarial review found a missed command-mode transition: a delayed End refusal | ||
| 354 | could open a force menu over an armed prefix. Dismissing the menu left the | ||
| 355 | prefix armed, so later terminal input could become another End command. The | ||
| 356 | fix preserves command mode and prevents that unsolicited menu. | ||
| 357 | - The real-daemon regression failed on the previous release binary, then passed | ||
| 358 | after the fix. Eight scenarios hold a reply by pausing only an owned daemon, | ||
| 359 | then exercise command, focus, picker, resize, drag, replacement, detach and | ||
| 360 | accepted-End paths. Another real client causes the shared-session refusal. | ||
| 361 | Explicit refusal assertions distinguish an actual reply from a timeout; | ||
| 362 | detach deliberately records an unknown outcome. | ||
| 363 | - Final-build SSH/QUIC checks passed on the VM and verified fixture cleanup. | ||
| 364 | The GUI demo and compositor mouse recording were captured on an isolated Sway | ||
| 365 | output at 200%, keeping the sleeping desktop unchanged. | ||
| 366 | |||
| 367 | ### What to change | ||
| 368 | |||
| 369 | - The first Wayland stress setup tried to resize an unmapped window. Use a | ||
| 370 | compositor mapping rule or wait for mapping before fixture placement. | ||
| 371 | - A software-rendered headless compositor exceeded the frame-time budget even | ||
| 372 | though input remained responsive. Record renderer provenance as well as both | ||
| 373 | binaries' build modes; do not infer hardware performance from functional | ||
| 374 | headless checks. Keep performance runs separate from other GUI fixtures. | ||
| 375 | - Include the pinned compiler and writable cache directory in short agent briefs. | ||
| 376 | The implementer's local validation attempt used a blocked cache and could not | ||
| 377 | fetch dependencies; root's existing cache completed the final native gates. | ||
| 378 | |||
| 379 | ### Validation and demo | ||
| 380 | |||
| 381 | `make ci`, 44 native unit tests, 63 native integration scenarios (including the | ||
| 382 | separately run old-daemon picker case), Wayland lifecycle/scale checks, real | ||
| 383 | compositor mouse input and SSH/QUIC acceptance passed. The GPU-backed Sway stress | ||
| 384 | run passed unchanged budgets: shared-daemon sampled input maximum 50.4 ms, | ||
| 385 | separate-daemon 65.3 ms, frame p99 16.0 ms, 113.7 MB over 30.1 seconds. The software | ||
| 386 | run remains a recorded failure; no budget was relaxed. Exact logs and measurement | ||
| 387 | limits are in the delivery plan. An earlier CI process received SIGTERM before | ||
| 388 | completion; the subsequent full CI run passed. | ||
| 389 | |||
| 390 | The [private final demo](https://charizard.folk-amberjack.ts.net/sprint5/) contains | ||
| 391 | the 61.2-second lifecycle/raw-output trial and a 23.4-second mouse recording. Both | ||
| 392 | use the final release GUI on isolated Sway at 200%, with no audio. Test fixtures | ||
| 393 | and compositors were stopped; only the explicitly shared demo service remains. | ||
| 394 | The implementation is ready for user acceptance; no approval is inferred. | ||
| 395 | |||
| 396 | ### Delegation and retained debt | ||
| 397 | |||
| 398 | Luna performed the opening assessment and focused implementation. A new Terra | ||
| 399 | reviewer could not start because old agent threads exhausted the session limit. | ||
| 400 | Root disclosed the fallback and reused the existing reviewer with a narrow brief; | ||
| 401 | that reviewer used the more expensive inherited model. The pair resolved one | ||
| 402 | production finding directly, and root independently reproduced it and validated | ||
| 403 | the integrated result. Per-agent token/cost totals were unavailable, so this | ||
| 404 | sprint does not establish savings from the intended Luna/Terra pair. Retain those | ||
| 405 | model defaults, and start the next sprint in a fresh session with both slots free. | ||
| 406 | |||
| 407 | The SSH announce EOF/authentication classification limit remains in | ||
| 408 | `src/client/session_pump.zig` and the shared handoff layer. Some permanent auth | ||
| 409 | failures receive bounded reconnect attempts. Reasons stay visible; End and | ||
| 410 | session creation are never retried. The final reviewer accepted deferral to a | ||
| 411 | shared handoff classification change, whose owner must preserve join-only | ||
| 412 | restoration and bounded shutdown. Tabs UI and pane rearrangement remain outside | ||
| 413 | this milestone. | ||
| 414 | |||
| 415 | ### Actions for the next authorized sprint | ||
| 416 | |||
| 417 | - [ ] Record final Sprint 5 demo acceptance and any requested ergonomic adjustment; | ||
| 418 | distinguish recorded approval from any hands-on trial. | ||
| 419 | - [ ] Start with available Luna/Terra slots and short briefs containing the pinned | ||
| 420 | compiler/cache command; record model selection, review yield and available cost | ||
| 421 | evidence without estimating missing token totals. | ||
| 422 | - [ ] When shared handoff errors are changed, separate unreachable-host EOF from | ||
| 423 | authentication refusal and verify bounded reconnect and join-only restore. | ||
docs/native-sprint-workflow.md
| Old | New | ||
|---|---|---|---|
| @@ -45,6 +45,13 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan | |||
| 45 | exercise SSH/QUIC when changes affect their behavior. Create and clean up only | 45 | exercise SSH/QUIC when changes affect their behavior. Create and clean up only |
| 46 | owned fixture sessions and daemons. | 46 | owned fixture sessions and daemons. |
| 47 | 47 | ||
| 48 | An isolated compositor can exercise scale changes without changing the user's | ||
| 49 | monitor. Start it in its own process session. Wait for a window to map before | ||
| 50 | resizing it, or use a compositor mapping rule for fixture geometry. Record the | ||
| 51 | compositor's renderer: a software compositor is useful for functional checks, | ||
| 52 | but does not establish hardware frame-time performance. Run performance checks | ||
| 53 | separately from recordings and other GUI fixtures on that compositor. | ||
| 54 | |||
| 48 | For responsiveness, run `make native-stress` on Linux. It builds both binaries | 55 | For responsiveness, run `make native-stress` on Linux. It builds both binaries |
| 49 | in ReleaseSafe from the same build graph and drives raw `cat /dev/random` in | 56 | in ReleaseSafe from the same build graph and drives raw `cat /dev/random` in |
| 50 | one pane while probing neighbours on the same daemon and a separate daemon. | 57 | one pane while probing neighbours on the same daemon and a separate daemon. |
docs/superpowers/plans/2026-09-05-native-tiling.md
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +1,9 @@ | |||
| 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 4 implemented, reviewed, and validated; recorded-demo acceptance | 4 | Status: Sprint 5 implemented, reviewed, and validated; final demo acceptance pending. |
| 5 | pending. Sprint 3 acceptance passed. | 5 | Sprint 4 is implemented, reviewed, and validated; separate recorded-demo |
| 6 | acceptance was not given. Sprint 3 acceptance passed. | ||
| 6 | 7 | ||
| 7 | ## Working agreement | 8 | ## Working agreement |
| 8 | 9 | ||
| @@ -504,7 +505,8 @@ Implemented lifecycle/recovery, native persistence, and restore integration. | |||
| 504 | Opening cleanup is `aa5e977`. Closing cleanup shares attachment startup for add | 505 | Opening cleanup is `aa5e977`. Closing cleanup shares attachment startup for add |
| 505 | and restore, consolidates modal held-key ownership and popup reset, and consumes | 506 | and restore, consolidates modal held-key ownership and popup reset, and consumes |
| 506 | End outcomes through Runtime's captured status. No unresolved production review | 507 | End outcomes through Runtime's captured status. No unresolved production review |
| 507 | finding remains. Recorded-demo acceptance is pending; Sprint 5 has not started. | 508 | finding remains. Separate recorded-demo acceptance was not given; the user later |
| 509 | explicitly authorized proceeding to Sprint 5, whose final demo includes lifecycle. | ||
| 508 | 510 | ||
| 509 | The native controls are prefix `d` to detach, `x` to request End, and `p` for | 511 | The native controls are prefix `d` to detach, `x` to request End, and `p` for |
| 510 | Retry/Choose session/Detach. End travels over the pane's own connection, respecting | 512 | Retry/Choose session/Detach. End travels over the pane's own connection, respecting |
| @@ -561,3 +563,81 @@ HTTPS 206 byte-range delivery. The loopback server is | |||
| 561 | `tailscale serve --https=443 --set-path /sprint4 off`, then stop the owned server; | 563 | `tailscale serve --https=443 --set-path /sprint4 off`, then stop the owned server; |
| 562 | earlier demo routes remain available. The recording does not claim a hands-on | 564 | earlier demo routes remain available. The recording does not claim a hands-on |
| 563 | trial or a live remote host outage; remote behavior is validated separately. | 565 | trial or a live remote host outage; remote behavior is validated separately. |
| 566 | |||
| 567 | |||
| 568 | ## Sprint 5 — final review and ergonomic trial | ||
| 569 | |||
| 570 | The user explicitly authorized this sprint. Opening and closing cleanup reviews | ||
| 571 | found no justified structural refactor. Adversarial review found one missed | ||
| 572 | transition: a delayed shared-session End refusal could open a force menu while | ||
| 573 | command mode was armed, leaving the prefix active after dismissal. The focused | ||
| 574 | fix prevents that menu from interrupting command mode. Other lifecycle, runtime, | ||
| 575 | persistence ownership and stale-generation checks passed the final review. | ||
| 576 | |||
| 577 | Root's real-daemon command-mode regression failed on the previous release binary | ||
| 578 | (`/tmp/muxg-sprint5-command-before.log`) and passed after the fix. The new matrix | ||
| 579 | in `test/native_lifecycle.py` pauses only owned daemons and covers command, focus, | ||
| 580 | picker, resize, drag, replacement, detach and accepted-End transitions. Another | ||
| 581 | real client supplies shared-session refusals. Non-detach refusal paths assert | ||
| 582 | the actual daemon reason, preventing timeouts from falsely passing. Detach keeps | ||
| 583 | the remote outcome explicitly unknown. Paused daemons resume in cleanup. | ||
| 584 | |||
| 585 | Validation on the reviewed source: | ||
| 586 | |||
| 587 | - ReleaseSafe daemon/GUI pair and **44/44 native unit tests**; complete native | ||
| 588 | integration: **10 viewer + 14 tiling + 10 picker + 8 resize + 21 lifecycle**. | ||
| 589 | `/tmp/muxg-sprint5-build.log`, `/tmp/muxg-sprint5-native-e2e.log`. The optional | ||
| 590 | tenth picker scenario ran separately against the retained prior-sprint daemon: | ||
| 591 | `/tmp/muxg-sprint5-old-daemon.log` (passed). | ||
| 592 | - All **21 lifecycle scenarios on Wayland at 200%**, including the new matrix: | ||
| 593 | `/tmp/muxg-sprint5-lifecycle-sway.log`. Scale transitions **200% → 100% → | ||
| 594 | 150% → 200%**, stale-drag cancellation, independent kernel PTY sizes and | ||
| 595 | retained pixels passed: `/tmp/muxg-sprint5-scale.log`. | ||
| 596 | - Real compositor mouse/button movement on the final binary passed both axes, | ||
| 597 | release and PTY checks: `/tmp/muxg-sprint5-mouse/record.log`. | ||
| 598 | - Real VM SSH fallback and direct QUIC lifecycle **3/3** with verified cleanup: | ||
| 599 | `/tmp/muxg-sprint5-remote/validation.log`. Both local demo binaries and the | ||
| 600 | uploaded static remote daemon were built in ReleaseSafe from this tree. | ||
| 601 | - `make native-stress` passed offscreen and on GPU-backed isolated Sway at 200%: | ||
| 602 | `/tmp/muxg-sprint5-stress-offscreen.log` and | ||
| 603 | `/tmp/muxg-sprint5-stress-gpu-sway.log`. Offscreen sampled input maxima were | ||
| 604 | **15.3 ms** for both neighbours. On Sway they were **50.4 ms shared-daemon** and | ||
| 605 | **65.3 ms separate-daemon**, with **113,704,475 bytes / 30.1 seconds** and frame | ||
| 606 | p99 **16,040 us**. Input figures are observed upper bounds with 5 ms polling. | ||
| 607 | Reports: `/tmp/muxg-tiling-0v2lvdd9/stress-result.json` and | ||
| 608 | `/tmp/muxg-tiling-q3hz7zxu/stress-result.json`. | ||
| 609 | - **`make ci` passed**: `/tmp/muxg-sprint5-ci-final.log`, including repository | ||
| 610 | checks, end-to-end and agent scenarios. Throughput: 4 ms solo, 48 ms attached, | ||
| 611 | 91 ms detached repaint. The earlier run received SIGTERM without a reported | ||
| 612 | assertion failure and is not counted as a completed gate. | ||
| 613 | |||
| 614 | The first Sway stress attempt raced window mapping; the isolated compositor now | ||
| 615 | sizes fixtures when they map. A software-rendered run exceeded the frame budget | ||
| 616 | (p99 55.1 ms) despite responsive input. It is retained in | ||
| 617 | `/tmp/muxg-sprint5-stress-sway-final.log`, not counted as a pass. The final run used | ||
| 618 | Sway's verified AMD/radeonsi renderer with the same budgets and ran without other | ||
| 619 | GUI fixtures on that compositor. Test compositors have been stopped; the sleeping | ||
| 620 | physical desktop was unchanged. | ||
| 621 | |||
| 622 | Final trial: [private video page](https://charizard.folk-amberjack.ts.net/sprint5/). | ||
| 623 | The main recording is **61.2 seconds, 1100×700, H.264, no audio**, with three | ||
| 624 | continuous clips joined across window restarts. It shows host-picker cancellation, | ||
| 625 | raw output with usable neighbours, keyboard resize, delayed End, Cancel-first | ||
| 626 | confirmation, restore, an unavailable target returning, missing-session recovery, | ||
| 627 | Detach and End. A **23.4-second mouse recording** verifies real compositor input. | ||
| 628 | Both were recorded on isolated Sway at 200% using local daemon targets; SSH/QUIC | ||
| 629 | was validated separately. No video-based latency measurement is claimed. | ||
| 630 | |||
| 631 | Retained videos are `native-tiling-sprint5.mp4` and | ||
| 632 | `native-tiling-sprint5-mouse.mp4` under | ||
| 633 | `/home/xanderle/.codex/visualizations/2026/09/04/01a06dd2-1bb6-79c3-8a66-bc6bd8f2dedf/`. | ||
| 634 | The owned page/video server is `/tmp/muxg-sprint5-demo/server.py` on loopback | ||
| 635 | 18769; both videos passed HTTPS 206 byte-range checks. Teardown removes only | ||
| 636 | `tailscale serve --https=443 --set-path /sprint5 off`, then stops that server. | ||
| 637 | Earlier demo routes remain available. Scripts and assertions are retained under | ||
| 638 | `/tmp/muxg-sprint5-demo/` and `/tmp/muxg-sprint5-mouse/`. | ||
| 639 | |||
| 640 | Reviewer approval and implementation are complete; user demo acceptance and any | ||
| 641 | requested ergonomic adjustments remain pending. SSH EOF/authentication error | ||
| 642 | classification is retained with the follow-up owner/trigger in RETRO.md. Tabs UI | ||
| 643 | and rearranging panes are outside the delivered milestone. | ||
docs/superpowers/specs/2026-09-05-native-tiling-design.md
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +1,9 @@ | |||
| 1 | # Native tiling — draft design and delivery plan | 1 | # Native tiling — draft design and delivery plan |
| 2 | 2 | ||
| 3 | 2026-09-05. Status: Sprint 4 lifecycle and persistence implemented, reviewed, and | 3 | 2026-09-05. Status: the native tiling milestone is implemented, reviewed, and |
| 4 | validated; recorded-demo acceptance pending. Sprint 3 was accepted after its | 4 | validated through Sprint 5; final recorded-demo acceptance is pending. Separate |
| 5 | mouse-focused recording. | 5 | Sprint 4 demo approval was not given; the user explicitly authorized advancing |
| 6 | to Sprint 5. Sprint 3 was accepted after its mouse-focused recording. | ||
| 6 | 7 | ||
| 7 | Execution: [delegated work packages and review gates](../plans/2026-09-05-native-tiling.md). | 8 | Execution: [delegated work packages and review gates](../plans/2026-09-05-native-tiling.md). |
| 8 | 9 | ||
src/gui/frame.zig
| Old | New | ||
|---|---|---|---|
| @@ -644,7 +644,7 @@ const Events = struct { | |||
| 644 | .accepted => try self.detach(pending.key.pane), | 644 | .accepted => try self.detach(pending.key.pane), |
| 645 | .refused => { | 645 | .refused => { |
| 646 | self.setNotice(result.reasonText()); | 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) { | 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 and !self.command_mode) { |
| 648 | self.recovery = .{ .kind = .force_end, .key = pending.key }; | 648 | self.recovery = .{ .kind = .force_end, .key = pending.key }; |
| 649 | self.recovery.?.setNotice(result.reasonText()); | 649 | self.recovery.?.setNotice(result.reasonText()); |
| 650 | } | 650 | } |
| @@ -1125,6 +1125,25 @@ test "delayed End refusal respects changed focus and recovery context names its | |||
| 1125 | try std.testing.expectEqualStrings("End for all clients", popup.lines[2].bytes[0..popup.lines[2].len]); | 1125 | try std.testing.expectEqualStrings("End for all clients", popup.lines[2].bytes[0..popup.lines[2].len]); |
| 1126 | } | 1126 | } |
| 1127 | 1127 | ||
| 1128 | test "delayed End refusal does not open force menu during command mode" { | ||
| 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 id = try rt.add(.{ .via = "cat" }, "end-target", 800, 600, metrics); | ||
| 1134 | var wake: Wake = .{ .event_type = c.SDL_EVENT_USER }; | ||
| 1135 | 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 }; | ||
| 1136 | defer events.deinit(); | ||
| 1137 | try events.relayout(); | ||
| 1138 | const key = rt.get(id).?.key; | ||
| 1139 | events.pending_end = .{ .key = key, .request = 1 }; | ||
| 1140 | rt.get(id).?.status.ending = .{ .request = 1, .phase = .refused, .others = 1 }; | ||
| 1141 | try events.pollEnd(); | ||
| 1142 | try std.testing.expect(events.recovery == null); | ||
| 1143 | try std.testing.expect(events.pending_end == null); | ||
| 1144 | try std.testing.expect(events.command_mode); | ||
| 1145 | } | ||
| 1146 | |||
| 1128 | test "held detach removes one pane and recovery Enter stays consumed after closing" { | 1147 | test "held detach removes one pane and recovery Enter stays consumed after closing" { |
| 1129 | const a = std.testing.allocator; | 1148 | const a = std.testing.allocator; |
| 1130 | var rt = runtime.Runtime.init(a, .{}); | 1149 | var rt = runtime.Runtime.init(a, .{}); |
test/native_lifecycle.py
| Old | New | ||
|---|---|---|---|
| @@ -483,6 +483,115 @@ def recovery_workflow(mux, muxg): | |||
| 483 | rig.close() | 483 | rig.close() |
| 484 | 484 | ||
| 485 | 485 | ||
| 486 | def delayed_end_transitions(mux, muxg): | ||
| 487 | # Stop only our real daemon to hold its reply across ordinary GUI input. | ||
| 488 | # A second real client supplies the daemon's refusal; no wire reply is forged. | ||
| 489 | for transition in ('command', 'focus', 'picker', 'resize', 'drag', 'replacement', 'detach', 'accepted'): | ||
| 490 | rig = LifecycleRig(mux, muxg) | ||
| 491 | paused = None | ||
| 492 | try: | ||
| 493 | a, daemon = rig.daemon('delayed-end') | ||
| 494 | b, _ = rig.daemon('healthy-neighbour') | ||
| 495 | rig.start_gui(a, b, 'gui-delayed-' + transition) | ||
| 496 | rig.host('--sock ' + b, 'v') | ||
| 497 | rig.choose('0') | ||
| 498 | state = rig.wait_state(lambda s: len(s['panes']) == 2 and | ||
| 499 | all(p['phase'] == 'attached' for p in s['panes'])) | ||
| 500 | origin, neighbour = [p['id'] for p in state['panes']] | ||
| 501 | generation = by_id(state)[origin]['generation'] | ||
| 502 | holder = None if transition == 'accepted' else rig.holder(a, 'left') | ||
| 503 | rig.focus(origin) | ||
| 504 | daemon.send_signal(signal.SIGSTOP) | ||
| 505 | paused = daemon | ||
| 506 | rig.chord('x') | ||
| 507 | rig.wait_state(lambda s: s['pending_end'] is not None) | ||
| 508 | rig.key('escape') | ||
| 509 | rig.wait_state(lambda s: s['recovery'] is None) | ||
| 510 | |||
| 511 | if transition in ('focus', 'accepted'): | ||
| 512 | rig.focus(neighbour) | ||
| 513 | elif transition == 'command': | ||
| 514 | rig.key('prefix') | ||
| 515 | rig.wait_state(lambda s: s['command_mode']) | ||
| 516 | elif transition == 'picker': | ||
| 517 | rig.chord('enter') | ||
| 518 | rig.picker('hosts') | ||
| 519 | elif transition == 'resize': | ||
| 520 | rig.chord('r') | ||
| 521 | rig.wait_state(lambda s: s['resize_mode']) | ||
| 522 | elif transition == 'drag': | ||
| 523 | state = rig.state() | ||
| 524 | rect = state['dividers'][0]['rect'] | ||
| 525 | point = rig.point(state, rect['x'] + rect['w'] / 2, | ||
| 526 | rect['y'] + rect['h'] / 4) | ||
| 527 | rig.send('mousedown:' + point) | ||
| 528 | rig.wait_state(lambda s: s['drag'] is not None) | ||
| 529 | elif transition == 'replacement': | ||
| 530 | rig.recover('Choose session') | ||
| 531 | state = rig.wait_state(lambda s: 'pending End' in s['notice']) | ||
| 532 | require(state['picker'] is None and by_id(state)[origin]['generation'] == generation, | ||
| 533 | 'replacement started while its End outcome was pending') | ||
| 534 | # Leave the recovery menu open while the refusal arrives. | ||
| 535 | rig.chord('p') | ||
| 536 | rig.wait_state(lambda s: s['recovery'] is not None) | ||
| 537 | elif transition == 'detach': | ||
| 538 | rig.chord('d') | ||
| 539 | rig.wait_state(lambda s: origin not in by_id(s)) | ||
| 540 | |||
| 541 | daemon.send_signal(signal.SIGCONT) | ||
| 542 | paused = None | ||
| 543 | state = rig.wait_state(lambda s: s['pending_end'] is None) | ||
| 544 | if transition == 'accepted': | ||
| 545 | state = rig.wait_state(lambda s: origin not in by_id(s)) | ||
| 546 | eventually(lambda: 'left' not in sessions(a), 'accepted End left its original session alive') | ||
| 547 | require(state['focus'] == neighbour and neighbour in by_id(state), | ||
| 548 | 'late accepted End removed or focused the wrong pane') | ||
| 549 | else: | ||
| 550 | require('left' in sessions(a) and holder.poll() is None, | ||
| 551 | 'delayed shared-session End bypassed the daemon refusal') | ||
| 552 | if transition != 'detach': | ||
| 553 | require(state['notice'] == 'others attached', | ||
| 554 | 'expected the real daemon refusal, not a timeout or unknown outcome') | ||
| 555 | if transition == 'replacement': | ||
| 556 | require(state['recovery'] is not None and | ||
| 557 | all(row['label'] != 'End for all clients' for row in state['recovery']['rows']), | ||
| 558 | 'late refusal replaced the active recovery menu') | ||
| 559 | else: | ||
| 560 | require(state['recovery'] is None, 'late refusal installed a force menu in another context') | ||
| 561 | if transition == 'command': | ||
| 562 | require(state['command_mode'], 'late refusal interrupted command mode') | ||
| 563 | elif transition == 'picker': | ||
| 564 | require(state['picker'] is not None, 'late refusal dismissed the picker') | ||
| 565 | elif transition == 'resize': | ||
| 566 | require(state['resize_mode'], 'late refusal interrupted resize mode') | ||
| 567 | elif transition == 'drag': | ||
| 568 | require(state['drag'] is not None, 'late refusal interrupted the active drag') | ||
| 569 | rig.send('mouseup:' + point) | ||
| 570 | if transition in ('command', 'picker', 'resize', 'replacement'): | ||
| 571 | rig.key('escape') | ||
| 572 | if transition == 'replacement': | ||
| 573 | rig.recover('Choose session') | ||
| 574 | rig.picker('hosts') | ||
| 575 | rig.choose('--sock ' + b) | ||
| 576 | rig.picker('sessions') | ||
| 577 | rig.choose('0') | ||
| 578 | state = rig.wait_state(lambda s: by_id(s)[origin]['generation'] > generation and | ||
| 579 | by_id(s)[origin]['phase'] == 'attached') | ||
| 580 | require(state['recovery'] is None, 'old End refusal leaked into replacement') | ||
| 581 | rig.release_holder(holder) | ||
| 582 | rig.mark(neighbour, b, '0', 'DELAYED-END-' + transition.upper()) | ||
| 583 | rig.assert_cli_untouched() | ||
| 584 | rig.quit() | ||
| 585 | rig.ok('real delayed End preserves target and GUI context through ' + transition) | ||
| 586 | except BaseException: | ||
| 587 | rig.failure_artifacts() | ||
| 588 | raise | ||
| 589 | finally: | ||
| 590 | if paused is not None and paused.poll() is None: | ||
| 591 | paused.send_signal(signal.SIGCONT) | ||
| 592 | rig.close() | ||
| 593 | |||
| 594 | |||
| 486 | def main(): | 595 | def main(): |
| 487 | require(len(sys.argv) == 3, 'usage: native_lifecycle.py MUX MUXG') | 596 | require(len(sys.argv) == 3, 'usage: native_lifecycle.py MUX MUXG') |
| 488 | mux, muxg = sys.argv[1:] | 597 | mux, muxg = sys.argv[1:] |
| @@ -505,6 +614,7 @@ def main(): | |||
| 505 | preservation_case(mux, muxg, 'malformed', b'{"version": 1, "tabs": [ BROKEN ORIGINAL\n') | 614 | preservation_case(mux, muxg, 'malformed', b'{"version": 1, "tabs": [ BROKEN ORIGINAL\n') |
| 506 | preservation_case(mux, muxg, 'unreadable', valid_bytes, unreadable=True) | 615 | preservation_case(mux, muxg, 'unreadable', valid_bytes, unreadable=True) |
| 507 | relative_target_reopen(mux, muxg) | 616 | relative_target_reopen(mux, muxg) |
| 617 | delayed_end_transitions(mux, muxg) | ||
| 508 | 618 | ||
| 509 | 619 | ||
| 510 | if __name__ == '__main__': | 620 | if __name__ == '__main__': |