106552cd
test: guard native raw-output responsiveness with release builds
a73x 2026-09-05 11:40
Commit message
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -31,7 +31,7 @@ SHA256 ?= shasum -a 256 | |||
| 31 | endif | 31 | endif |
| 32 | MUX_TARGET ?= x86_64-linux-musl | 32 | MUX_TARGET ?= x86_64-linux-musl |
| 33 | 33 | ||
| 34 | .PHONY: build check ci test e2e soak bench agent native native-e2e throughput vm coverage deps clean clean-deps xversion xversion-build install release release-mac mac-sdk mac xos provision-mac | 34 | .PHONY: build check ci test e2e soak bench agent native native-e2e native-stress throughput vm coverage deps clean clean-deps xversion xversion-build install release release-mac mac-sdk mac xos provision-mac |
| 35 | 35 | ||
| 36 | # The QUIC stack (deps/quic) is built on demand by build.zig, so no target | 36 | # The QUIC stack (deps/quic) is built on demand by build.zig, so no target |
| 37 | # here needs to depend on this one. It exists to make the one-time cost | 37 | # here needs to depend on this one. It exists to make the one-time cost |
| @@ -277,6 +277,9 @@ native: mac-sdk | |||
| 277 | native-e2e: mac-sdk | 277 | native-e2e: mac-sdk |
| 278 | $(ZIG) build native-e2e -Doptimize=ReleaseSafe | 278 | $(ZIG) build native-e2e -Doptimize=ReleaseSafe |
| 279 | 279 | ||
| 280 | native-stress: mac-sdk | ||
| 281 | $(ZIG) build native-stress -Doptimize=ReleaseSafe | ||
| 282 | |||
| 280 | # Cross-version gate (test/xversion.sh): this tree's client against a | 283 | # Cross-version gate (test/xversion.sh): this tree's client against a |
| 281 | # previous version's daemon and back, each daemon in a container. | 284 | # previous version's daemon and back, each daemon in a container. |
| 282 | # | 285 | # |
README.md
| Old | New | ||
|---|---|---|---|
| @@ -25,11 +25,26 @@ packages; OpenGL functions are loaded through SDL, with no direct libGL | |||
| 25 | link. It is outside the default build and CI gates: | 25 | 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 # Debug development build and no-window unit tests |
| 29 | make native-e2e # ReleaseSafe pane, picker, resize and restore checks; needs python3 | 29 | make native-e2e # ReleaseSafe pane, picker, resize and restore checks; needs python3 |
| 30 | make native-stress # Linux: raw cat /dev/random, shared and separate daemon neighbours | ||
| 30 | ./zig-out/bin/muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N] | 31 | ./zig-out/bin/muxg [TARGET] [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--font-px N] |
| 31 | ``` | 32 | ``` |
| 32 | 33 | ||
| 34 | For interactive use and performance measurements, build **both** binaries in | ||
| 35 | ReleaseSafe. Debug enables expensive terminal integrity checks in the daemon; | ||
| 36 | a release GUI alone does not avoid that cost. A separate output directory keeps | ||
| 37 | subsequent development checks from replacing these binaries: | ||
| 38 | |||
| 39 | ```sh | ||
| 40 | deps/zig/zig build install native -Doptimize=ReleaseSafe -p dist/native-release | ||
| 41 | ./dist/native-release/bin/mux d start -d | ||
| 42 | ./dist/native-release/bin/muxg | ||
| 43 | ``` | ||
| 44 | |||
| 45 | The daemon command starts a new daemon; an already running daemon retains its | ||
| 46 | existing build until it is upgraded or restarted. | ||
| 47 | |||
| 33 | `muxg` displays daemon sessions in native terminal panes. With no arguments it | 48 | `muxg` displays daemon sessions in native terminal panes. With no arguments it |
| 34 | restores the saved native workspace; first launch opens the host/session picker. | 49 | 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 | 50 | For local sessions, start the daemon with `mux d start -d`. It never starts a local |
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -272,6 +272,9 @@ within the release review or should remain an explicit follow-up. | |||
| 272 | Owner: the sprint lead and implementation/review pair. Close each with evidence | 272 | Owner: the sprint lead and implementation/review pair. Close each with evidence |
| 273 | at the final milestone retrospective. | 273 | at the final milestone retrospective. |
| 274 | 274 | ||
| 275 | - [ ] Include `make native-stress` and a real Sway raw-output run in the final | ||
| 276 | release review; build both demo binaries together in an isolated ReleaseSafe | ||
| 277 | prefix and report sampled input latency separately from frame timing. | ||
| 275 | - [ ] Obtain explicit Sprint 4 demo acceptance before beginning the release review. | 278 | - [ ] Obtain explicit Sprint 4 demo acceptance before beginning the release review. |
| 276 | - [ ] Start with a bounded review/cleanup of the accumulated native workflow, then | 279 | - [ ] 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. | 280 | retain a closing cleanup pass; avoid introducing tabs UI or terminal tiling changes. |
| @@ -283,3 +286,37 @@ at the final milestone retrospective. | |||
| 283 | shared sessions, state ownership/failures, high DPI, and cleanup of owned fixtures. | 286 | shared sessions, state ownership/failures, high DPI, and cleanup of owned fixtures. |
| 284 | - [ ] Record final demo acceptance separately from automation and any hands-on | 287 | - [ ] Record final demo acceptance separately from automation and any hands-on |
| 285 | ergonomic feedback, update the spec's completion status, and commit the milestone. | 288 | ergonomic feedback, update the spec's completion status, and commit the milestone. |
| 289 | |||
| 290 | ### Sprint 4 follow-up: raw-output responsiveness | ||
| 291 | |||
| 292 | The user's `cat /dev/random` question exposed two validation gaps: printable | ||
| 293 | floods do not exercise arbitrary terminal controls, and checking the GUI's build | ||
| 294 | mode does not establish the daemon's mode. The first ad-hoc run paired a | ||
| 295 | ReleaseSafe GUI with a Debug daemon left in `zig-out` by the repository gates. | ||
| 296 | Its same-daemon neighbour took up to 843 ms to appear, while a separate-daemon | ||
| 297 | neighbour appeared within 121 ms. Binary inspection confirmed Debug; the | ||
| 298 | daemon's terminal engine enables expensive integrity checks in that mode. | ||
| 299 | |||
| 300 | Rebuilding only the daemon in ReleaseSafe resolved the observed difference. | ||
| 301 | The same real Sway scenario consumed about 56 MB over 30 seconds, versus 0.88 MB | ||
| 302 | in Debug, with both neighbours observed within 121 ms. Close/reopen, menus and | ||
| 303 | divider resizing passed. Those latency figures include polling delay and are | ||
| 304 | upper bounds, not precise intrinsic timings. Independent offscreen replication | ||
| 305 | agreed; there was no evidence warranting a production scheduler change. | ||
| 306 | |||
| 307 | The follow-up adds `make native-stress`, whose build graph supplies both release | ||
| 308 | binaries and refuses Debug. The Linux test uses raw output, independently checks | ||
| 309 | producer identity and sustained daemon reads, probes shared and separate daemon | ||
| 310 | neighbours, and repeats lifecycle/resize checks during the flood. A tighter | ||
| 311 | observation interval and a 250 ms input-to-painted limit guard the reported | ||
| 312 | failure. Demo instructions now use a separate release prefix. The original | ||
| 313 | measurement is retained here as a corrected finding, not a product defect. | ||
| 314 | |||
| 315 | The permanent test passed offscreen and on Sway at 200%: with 5 ms observation | ||
| 316 | polling, shared-daemon response was at most 65.2 ms and separate-daemon response | ||
| 317 | 51.1 ms on Sway, while about 57 MB ran over 30 seconds. Frame p99 stayed below | ||
| 318 | 17.3 ms, and the lifecycle/resize checks passed. The Debug build target failed | ||
| 319 | before launching fixtures as intended. Adversarial review accepted the test | ||
| 320 | after adding its Makefile target to `.PHONY`. | ||
| 321 | As a negative control, deliberately bypassing the build target and pairing the | ||
| 322 | release GUI with the Debug daemon failed the new latency assertion at 878 ms. | ||
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -1180,6 +1180,18 @@ pub fn build(b: *std.Build) void { | |||
| 1180 | 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)"); |
| 1181 | native_e2e_step.dependOn(&native_lifecycle.step); | 1181 | native_e2e_step.dependOn(&native_lifecycle.step); |
| 1182 | 1182 | ||
| 1183 | // Both paths come from this build graph: a ReleaseSafe GUI beside a stale | ||
| 1184 | // Debug daemon gives misleading latency numbers under raw terminal output. | ||
| 1185 | const native_stress_step = b.step("native-stress", "Check raw-output native pane responsiveness (Linux, release build)"); | ||
| 1186 | if (optimize != .ReleaseSafe and optimize != .ReleaseFast) { | ||
| 1187 | native_stress_step.dependOn(&b.addFail("native-stress requires -Doptimize=ReleaseSafe or ReleaseFast for both binaries").step); | ||
| 1188 | } else { | ||
| 1189 | const native_stress = b.addSystemCommand(&.{ "python3", "-B", "test/native_stress.py" }); | ||
| 1190 | native_stress.addArtifactArg(mux_exe); | ||
| 1191 | native_stress.addArtifactArg(muxg_exe); | ||
| 1192 | native_stress_step.dependOn(&native_stress.step); | ||
| 1193 | } | ||
| 1194 | |||
| 1183 | const soak = b.addSystemCommand(&.{"test/soak.sh"}); | 1195 | const soak = b.addSystemCommand(&.{"test/soak.sh"}); |
| 1184 | // The same list the e2e step passes, in the same order: soak IS that | 1196 | // The same list the e2e step passes, in the same order: soak IS that |
| 1185 | // suite run N times, so an argument added to one and not the other | 1197 | // suite run N times, so an argument added to one and not the other |
docs/native-sprint-workflow.md
| Old | New | ||
|---|---|---|---|
| @@ -19,6 +19,12 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan | |||
| 19 | acceptance criteria, and each other's names. Root owns integration, independent | 19 | acceptance criteria, and each other's names. Root owns integration, independent |
| 20 | acceptance, user communication, and commits. Delegate another task only when | 20 | acceptance, user communication, and commits. Delegate another task only when |
| 21 | it can progress independently. Load skills only where they help the work. | 21 | it can progress independently. Load skills only where they help the work. |
| 22 | |||
| 23 | Cost default: explicitly select GPT-5.6 Luna for bounded implementation and | ||
| 24 | GPT-5.6 Terra for adversarial review. Start with task-specific context instead | ||
| 25 | of inheriting the full conversation. Escalate a concrete unresolved problem | ||
| 26 | to a stronger model when needed. Root coordinates and integrates; assign each | ||
| 27 | investigation one owner so agents do not repeat it independently by accident. | ||
| 22 | 4. **Build a functional slice.** Establish the smallest model/interface contract, | 28 | 4. **Build a functional slice.** Establish the smallest model/interface contract, |
| 23 | then connect actual input, rendering, and daemon behavior. Keep changes small | 29 | then connect actual input, rendering, and daemon behavior. Keep changes small |
| 24 | enough to review. Avoid expanding into later sprint features. | 30 | enough to review. Avoid expanding into later sprint features. |
| @@ -35,6 +41,14 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan | |||
| 35 | exercise SSH/QUIC when changes affect their behavior. Create and clean up only | 41 | exercise SSH/QUIC when changes affect their behavior. Create and clean up only |
| 36 | owned fixture sessions and daemons. | 42 | owned fixture sessions and daemons. |
| 37 | 43 | ||
| 44 | For responsiveness, run `make native-stress` on Linux. It builds both binaries | ||
| 45 | in ReleaseSafe from the same build graph and drives raw `cat /dev/random` in | ||
| 46 | one pane while probing neighbours on the same daemon and a separate daemon. | ||
| 47 | It checks ongoing output, input through completed painted state, menus, | ||
| 48 | divider resizing, and close/reopen under load. Daemon inspection happens after | ||
| 49 | the timed paint observation. Report sampled latency as an upper bound; frame | ||
| 50 | timing alone does not establish input responsiveness. | ||
| 51 | |||
| 38 | For lifecycle work, independently observe the daemon after Detach and End, | 52 | For lifecycle work, independently observe the daemon after Detach and End, |
| 39 | including another attached client. Change focus and open another modal while | 53 | including another attached client. Change focus and open another modal while |
| 40 | a destructive request is pending; a later confirmation must still identify | 54 | a destructive request is pending; a later confirmation must still identify |
| @@ -55,6 +69,7 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan | |||
| 55 | make ci | 69 | make ci |
| 56 | deps/zig/zig build native native-test -Doptimize=ReleaseSafe | 70 | deps/zig/zig build native native-test -Doptimize=ReleaseSafe |
| 57 | make native-e2e | 71 | make native-e2e |
| 72 | make native-stress # Linux raw-output responsiveness | ||
| 58 | ``` | 73 | ``` |
| 59 | 74 | ||
| 60 | `make ci` excludes native tests. Use the pinned compiler. A sandbox denial is | 75 | `make ci` excludes native tests. Use the pinned compiler. A sandbox denial is |
| @@ -70,6 +85,17 @@ packages. Keep one sprint active, ending in a working demo and explicit acceptan | |||
| 70 | before closing the window so another application cannot enter the recording. | 85 | before closing the window so another application cannot enter the recording. |
| 71 | A slideshow of screenshots is useful evidence but is not a screen recording. | 86 | A slideshow of screenshots is useful evidence but is not a screen recording. |
| 72 | 87 | ||
| 88 | Build both demo binaries together in an isolated release prefix: | ||
| 89 | |||
| 90 | ```sh | ||
| 91 | deps/zig/zig build install native -Doptimize=ReleaseSafe -p dist/native-release | ||
| 92 | ``` | ||
| 93 | |||
| 94 | Pass `dist/native-release/bin/mux` and `dist/native-release/bin/muxg` to Rig. | ||
| 95 | `make ci` can leave a Debug daemon in `zig-out`; `make native` builds only the | ||
| 96 | GUI. Checking just `muxg --version` therefore cannot establish the daemon's | ||
| 97 | build mode. Use the build graph's artifact paths for automated stress tests. | ||
| 98 | |||
| 73 | For a close/reopen demo, stop the recorder before closing the first window and | 99 | 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 | 100 | resume on the new owned window after launch. Join those continuous clips and |
| 75 | disclose the restart cuts; do not capture the intervening desktop. | 101 | disclose the restart cuts; do not capture the intervening desktop. |
test/native_stress.py
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,156 @@ | |||
| 1 | #!/usr/bin/env python3 | ||
| 2 | """Raw cat /dev/random with same-daemon and separate-daemon GUI neighbours. | ||
| 3 | |||
| 4 | Run through `make native-stress`: its build graph supplies both release binaries. | ||
| 5 | Linux /proc provides independent producer identity and daemon read-progress | ||
| 6 | oracles. Producer write accounting misses GNU cat's splice path. | ||
| 7 | """ | ||
| 8 | import hashlib | ||
| 9 | import json | ||
| 10 | import os | ||
| 11 | from pathlib import Path | ||
| 12 | import shlex | ||
| 13 | import signal | ||
| 14 | import subprocess | ||
| 15 | import sys | ||
| 16 | import time | ||
| 17 | |||
| 18 | from native_lifecycle import LifecycleRig, start_persistent | ||
| 19 | from native_resize import by_id | ||
| 20 | from native_tiling import eventually, require | ||
| 21 | |||
| 22 | |||
| 23 | class StressRig(LifecycleRig): | ||
| 24 | # Sample completed painted state more closely than the general integration | ||
| 25 | # harness's 40 ms polling. These remain observed upper bounds, not GPU timings. | ||
| 26 | def artifact(self, command, suffix): | ||
| 27 | self.serial += 1 | ||
| 28 | path = self.root / (str(self.serial) + suffix) | ||
| 29 | self.send(command + ':' + str(path)) | ||
| 30 | deadline = time.monotonic() + 5 | ||
| 31 | while not path.exists() and self.gui.poll() is None and time.monotonic() < deadline: | ||
| 32 | time.sleep(.005) | ||
| 33 | require(path.exists(), 'GUI did not produce ' + command) | ||
| 34 | return path | ||
| 35 | |||
| 36 | def painted(self, pane, marker): | ||
| 37 | deadline = time.monotonic() + 2 | ||
| 38 | while time.monotonic() < deadline: | ||
| 39 | state = self.state() | ||
| 40 | if marker in by_id(state)[pane]['painted_text']: | ||
| 41 | return state | ||
| 42 | time.sleep(.005) | ||
| 43 | raise AssertionError('neighbour did not paint ' + marker) | ||
| 44 | |||
| 45 | |||
| 46 | def main(): | ||
| 47 | require(sys.platform == 'linux', 'native-stress currently requires Linux /proc') | ||
| 48 | r = StressRig(*sys.argv[1:3]) | ||
| 49 | producer = None | ||
| 50 | report = {'artifacts': str(r.root), 'samples': []} | ||
| 51 | report_path = r.root / 'stress-result.json' | ||
| 52 | |||
| 53 | def cat_alive(): | ||
| 54 | try: | ||
| 55 | return Path(f'/proc/{producer}/cmdline').read_bytes().startswith(b'cat\0/dev/random\0') | ||
| 56 | except FileNotFoundError: | ||
| 57 | return False | ||
| 58 | |||
| 59 | def read_count(pid): | ||
| 60 | fields = dict(line.split(': ', 1) for line in Path(f'/proc/{pid}/io').read_text().splitlines()) | ||
| 61 | return int(fields['rchar']) | ||
| 62 | |||
| 63 | def probe(pane, sock, session, iteration): | ||
| 64 | r.focus(pane) | ||
| 65 | marker = f'RAW-NEIGHBOUR-{iteration}-{pane}' | ||
| 66 | before = time.monotonic() | ||
| 67 | # The contiguous marker is absent from shell input, so echo alone cannot | ||
| 68 | # satisfy the check before the shell executes our command. | ||
| 69 | r.shell("printf '\\033[32mRAW-NEIGHBOUR-%s\\033[0m\\n' " + shlex.quote(f'{iteration}-{pane}')) | ||
| 70 | state = r.painted(pane, marker) | ||
| 71 | delay_ms = (time.monotonic() - before) * 1000 | ||
| 72 | report['samples'].append({'pane': pane, 'session': session, | ||
| 73 | 'observed_input_to_painted_ms': round(delay_ms, 1)}) | ||
| 74 | require(delay_ms < 250, f'neighbour exceeded 250 ms: {delay_ms:.1f} ms') | ||
| 75 | require(all(p['phase'] == 'attached' for p in state['panes']), 'pane lost attachment') | ||
| 76 | # Independently check the authoritative grid outside the latency interval. | ||
| 77 | r.wait_marker(sock, session, marker) | ||
| 78 | |||
| 79 | try: | ||
| 80 | version = subprocess.check_output([r.muxg, '--version'], text=True) | ||
| 81 | require('ReleaseSafe' in version or 'ReleaseFast' in version, 'stress requires release binaries') | ||
| 82 | report['binaries'] = {path: hashlib.sha256(Path(path).read_bytes()).hexdigest() | ||
| 83 | for path in (r.mux, r.muxg)} | ||
| 84 | refs = start_persistent(r) | ||
| 85 | ids = list(refs) | ||
| 86 | flooded, separate, shared = ids | ||
| 87 | report['panes'] = {'flooded': flooded, 'separate_daemon': separate, 'shared_daemon': shared} | ||
| 88 | daemon_pid = next(proc.pid for sock, proc in r.daemons if sock == refs[flooded][0]) | ||
| 89 | r.focus(flooded) | ||
| 90 | pid_path = r.root / 'random.pid' | ||
| 91 | r.shell('sh -c ' + shlex.quote('printf "%s\\n" $$ > ' + shlex.quote(str(pid_path)) + | ||
| 92 | '; exec cat /dev/random')) | ||
| 93 | eventually(lambda: pid_path.exists() and pid_path.stat().st_size, 'cat did not start') | ||
| 94 | producer = int(pid_path.read_text()) | ||
| 95 | eventually(cat_alive, 'owned producer did not exec cat /dev/random') | ||
| 96 | started = time.monotonic() | ||
| 97 | baseline = read_count(daemon_pid) | ||
| 98 | last_count = baseline | ||
| 99 | round_no = 0 | ||
| 100 | restarted = False | ||
| 101 | while time.monotonic() - started < 30: | ||
| 102 | require(cat_alive(), 'raw producer exited early') | ||
| 103 | for pane in (separate, shared): | ||
| 104 | probe(pane, *refs[pane], round_no) | ||
| 105 | if not restarted and time.monotonic() - started >= 10: | ||
| 106 | r.chord('p') | ||
| 107 | r.wait_state(lambda s: s['recovery'] is not None) | ||
| 108 | r.key('escape') | ||
| 109 | r.wait_state(lambda s: s['recovery'] is None) | ||
| 110 | r.drag('beside', dx=-r.state()['cell_w'] * 3) | ||
| 111 | r.kernel_sizes() | ||
| 112 | report['before_reopen_frames'], report['before_reopen_p99_us'] = r.frames() | ||
| 113 | r.quit() | ||
| 114 | r.launch_gui([], 'gui-raw-restored') | ||
| 115 | r.kernel_sizes() | ||
| 116 | report['reopened_during_flood'] = True | ||
| 117 | restarted = True | ||
| 118 | time.sleep(.15) | ||
| 119 | count = read_count(daemon_pid) | ||
| 120 | require(count - last_count > 16 * 1024, 'raw flood made insufficient progress this round') | ||
| 121 | last_count = count | ||
| 122 | round_no += 1 | ||
| 123 | report['active_seconds'] = round(time.monotonic() - started, 1) | ||
| 124 | report['daemon_bytes_read'] = last_count - baseline | ||
| 125 | report['final_frames'], report['final_p99_us'] = r.frames() | ||
| 126 | require(restarted and cat_alive(), 'reopen did not run during active raw output') | ||
| 127 | require(report['daemon_bytes_read'] > 256 * 1024, 'raw flood made insufficient total progress') | ||
| 128 | require(report['before_reopen_frames'] > 0 and report['final_frames'] > 0, 'no frames during flood') | ||
| 129 | require(max(report['before_reopen_p99_us'], report['final_p99_us']) < 20000, | ||
| 130 | 'raw flood exceeded 20 ms frame p99 budget') | ||
| 131 | os.kill(producer, signal.SIGTERM) | ||
| 132 | producer = None | ||
| 133 | for pane in (separate, shared): | ||
| 134 | probe(pane, *refs[pane], 'after-stop') | ||
| 135 | r.quit() | ||
| 136 | r.assert_cli_untouched() | ||
| 137 | report['passed'] = True | ||
| 138 | print(json.dumps(report, indent=2), flush=True) | ||
| 139 | print('native stress OK; report:', report_path, flush=True) | ||
| 140 | except BaseException: | ||
| 141 | r.failure_artifacts() | ||
| 142 | raise | ||
| 143 | finally: | ||
| 144 | try: | ||
| 145 | if producer is not None and cat_alive(): | ||
| 146 | os.kill(producer, signal.SIGTERM) | ||
| 147 | except ProcessLookupError: | ||
| 148 | pass | ||
| 149 | try: | ||
| 150 | r.close() | ||
| 151 | finally: | ||
| 152 | report_path.write_text(json.dumps(report, indent=2) + '\n') | ||
| 153 | |||
| 154 | |||
| 155 | if __name__ == '__main__': | ||
| 156 | main() | ||