sak-runner --attach shows a service slot's running view on its host and takes its keys #194

Merged
agra merged 5 commits from task/3665-s1-sak-runner-attach-shows-a-service-run into master 2026-10-30 20:35:04 +02:00
Owner

Tracker: #3665

A runner that runs as a service slot now keeps the running view's board and serves its view on a Unix socket. sak-runner --attach shows that view in a terminal on the host, over ssh too, and sends it keys.

What it delivers

  • The slot keeps the board. A slotted runner copies each chunk it prints to the runner log unchanged, and feeds the same lines to the board. It listens on ~/.sak-runner/view.<slot>.sock, which only its user can reach (mode 0600). It opens no network port.
  • sak-runner --attach [--slot a|b] connects to the slot the configuration's slot line names, or to the one given. It puts its terminal in raw mode, sends its size, each resize and its input, and writes what the runner draws. The pages and keys are the ones a terminal runner shows, drawn by runner/view.sx. The runner side is a std.tui peer session.
  • Leaving. d detach is in the footer of every page. d, ctrl-c, the attach's input ending, or its connection dropping end the attach and leave the runner as it was.
  • q asks how to stop, as at a terminal. In an attach, the box says launchd does not restart slot a once it stops (systemd on Linux). The farewell line goes to the attach and to the runner log.
  • Several views at once. Attaches are open together, each at its own size, with its own page, selection, log scroll and Flows follower. runner/view.sx is split into Host, one per runner (the board, the captured stdout, settling and the stop), and View, one viewer. The board wakes every open view.
  • Keys wait at the slot. What an attach types waits at the slot and goes to its view as the view's input takes it. Meanwhile the slot keeps sending the attach what the view draws. Once the view has ended, keys typed to it reach nothing.
  • Hand-over. Across an update's hand-over, an attach stays on the old slot while it finishes its steps. That slot then tells it the slot it handed over to. The attach says sak-runner: slot a handed over to slot b and attaches to slot b.
  • No runner. With no runner listening, --attach prints sak-runner: no runner listens on <socket> and exits 1.
  • The wire (runner/attach.sx). Each message is a kind byte, a 4-byte length and a payload. The attach sends z (size) and k (keys). The slot sends o (screen bytes), then d (detached), s (stopped, saying how) or h (handed over, naming the slot).
  • Docs. runner/README.md has an Attaching section after The console, and the flag paragraph and Tests describe --attach and tests/runner_attach.sx.

Tests

tests/runner_attach.sx serves sak in process and runs a slotted runner in process, with its stdout pointed at a file that stands in for the runner log.

  • Attaches over the socket:
    • the first frame is at the attach's size (header on row 1, footer on row 30);
    • a resize to 24×90;
    • ↓ moves the selection;
    • a second attach at 20×80 opens beside the first, with a selection of its own;
    • d, ctrl-c and input ending each detach, and the runner takes a step and holds its steps and channel after;
    • more keys than a view's input holds, typed while the view shows: the view draws on and takes each key, and q after them shows the stop box;
    • a size sent once a view's session closed, while the view waits for its Flows page's call to sak, reaches nothing, and more keys than a view's input holds, typed then, still leave the attach to be told it detached;
    • the stop box's launchd line;
    • an attach is told h b when the runner hands over, and s stopped · … when it is stopped from an attach.
  • Other checks: the socket's mode is srw-------, and the runner log keeps the console lines.
  • dist/sak-runner --attach on a pseudo-terminal: it shows the view, sends ↓, redraws on a resize of its own terminal, follows the hand-over to a stand-in for slot b, and detaches there (exit 0).
  • No runner: with no runner listening, it prints no runner listens on … and exits 1, for --slot a and for the configuration's slot=b.

tests/runner_handover.sx holds sak-runner --attach --slot a on a pseudo-terminal across the hand-over between real slot processes under its systemd stand-in. The attach follows slot a to slot b, shows slot b's view and detaches there with exit 0.

Fail-before. At master ce44f520 the test does not build: cannot read import 'runner/attach.sx'. Mutation arms of 895dba9f, each a git archive arm with dist/sak-runner rebuilt. The first failing check is the pinned one; later failures in an arm cascade from it:

mutation failing checks
session opened at the default size 12, first "the first frame is at the attach's size"
size messages after the first ignored 9, first "a resize draws the view at the new size"
d not detaching 1, "d detaches"
no chmod 0600 1, "only the slot's user reaches its socket"
no copy to the runner log 1, "the slot's lines still reach its log, as they were"
finish dropping the slot handed over to 3, the hand-over checks
the client not following h 2, "sak-runner --attach says slot a handed over to slot b …" and its detach

The existing tests that run a slotted or terminal runner pass at 895dba9f, run through tests/run.sh: runner_handover (163 s), runner_launchd (100 s, real launchd), runner_view (66 s), runner_install (12 s).

Review answers

  • A size sent after a view ended reached its freed session. When a view ended, its thread closed the attach's peer session, which frees the size a resize stores into and closes the session's signal pipe. Only then did it wait for the view's Flows follower to return from its call to sak. Meanwhile the relay still read the attach's messages, and each size made it resize the closed session: the size went into freed memory, and a byte went to whatever file then held the number of the session's signal pipe. Each attach now has a mutex and a closed flag. The view's thread sets the flag before it closes the session, and the relay resizes only while the flag is clear.

    • Regression, in tests/runner_attach.sx: the in-process sak's dispatch holds the GETs of /api/flow-runs while the test asks. An attach opens its Flows page, so its follower's call is held, then sends d. The test waits for the number of the session's signal pipe to be free, takes it for a pipe of its own (F_DUPFD_CLOEXEC), sends a size, and checks that its pipe received nothing. Then it lets the call go and checks that the attach is told it detached.
    • Fail-before: with the head's tests over the earlier runner/attach.sx, 4 runs of 4 failed "a size the attach sends then reaches nothing", and each process then died of SIGTRAP (exit 133) after the freed size was written. With the fix, runner_attach passed 7 runs of 7.
  • The hand-over through hold() had no test. tests/runner_handover.sx now holds sak-runner --attach --slot a on a pseudo-terminal across the hand-over between real slot processes. The client is the installed version file, so its command names the scene's directory. Once the old slot's process exits, the attach says sak-runner: slot a handed over to slot b and shows slot b's view, and d there ends it with sak-runner: detached from slot b, exit 0.

    • Fail-before: an arm without hold()'s line that names the slot handed over to, with dist/sak-runner rebuilt there, printed RUNNER HANDOVER FAIL (3 checks): "the attach says slot a handed over to slot b", "... and shows slot b's view" and "... whose detach ends the attach". The head passed (RUNNER HANDOVER OK, 158 s).
  • Keys typed while a view was ending held the relay for good (review 1975, F1). The relay wrote each k message to the view's input pipe with a blocking write. Once the view stopped reading, a burst larger than the pipe held the relay in that write. The attach was never told it detached, and the runner's stop and hand-over waited on that relay forever. The same write also wedged a view that was still showing. The relay is the only reader of what the view draws. While it waited for the view to take keys, a view that redrew on each key filled its screen pipe and then stopped taking keys.

    • Fix: keys now wait at the slot in a queue. The relay hands them to the view's input without blocking, only while the pipe has room, and meanwhile keeps reading the view's screen and the attach's messages. When the view ends, its thread closes its input right after closing its session, so keys handed after that fail at once and the relay drops them.

    • Regressions, in tests/runner_attach.sx. Both send one burst that is more than the view's input pipe holds: 8192 keys the view redraws on one by one, then a 128 KiB bracketed paste.

      • While the view shows: after the burst, q shows the stop box.
      • In the held-follower scene, after d: once the follower's call returns, the attach is told it detached.
      • A wedged relay would hang the test at the runner's stop. So when either check fails, the test prints RUNNER ATTACH FAIL and exits 1 within its 30 s wait.
    • Fail-before and pass-after, from git archive arms of 84eb16bb with the new test:

      arm result
      84eb16bb's runner/attach.sx 1 check failed, "… q after them shows the stop box", exit 1 in 35 s
      84eb16bb's runner/attach.sx, test without the live burst 1 check failed, "once the follower's call returns, the attach, which typed more meanwhile than a view's input holds, is told it detached", exit 1 in 37 s
      the review's fix alone: the view's thread closes its input, but the relay's write still blocks 1 check failed, the live one
      this fix RUNNER ATTACH OK in 8 s, then 3 runs of 3 OK, 5 s each
    • The review's probe sends a 256 KiB k message after "a size the attach sends then reaches nothing". A second probe sends 256 KiB while the view shows. At 84eb16bb, each failed its check and then hung until timeout killed it at 150 s. With the fix, both pass.

    • Not pinned: an arm that leaves the view's input open until the relay closes it after the view's thread ends also passes. The queue alone keeps the relay from blocking. Closing the input at once only stops keys from queuing for a view that takes no more.

The branch also merges master bc8ec978 (#3981) cleanly, so the gates below test the tree Merge would land.

Gates at the head

At 587a7d1e, with master bc8ec978 merged in (master has not moved since):

  • full: gate run 995, passed:
    • tests/run.sh: 71 files in 296 s, with runner_handover 159 s and runner_attach 7 s;
    • vitest: 448 passed;
    • release.sh and step9;
    • the deploy contract;
    • Playwright main config: 777 passed, 1 skipped;
    • Playwright runner config: 10 passed.
  • fast: gate run 992, passed. It ran on the same tree before the commit, so it is recorded at 84eb16bb.

Earlier rounds: at 84eb16bb, fast 984 and full 988 passed; at 895dba9f, fast 964 and full 967 passed.

Left open

On the way, the pinned sx (af8455c3) rejected a match that has a return or continue arm, an arm that discards a bool call's value, and an empty arm. The error is cannot coerce a value of type 'void' to 'bool'. No SX task covered it; the reproduction is in the step's summary for filing. The code here writes terminal output through FdWriter, as tui.Session does, so it does not meet that case.

Tracker: [#3665](https://sak.swipelab.com/tasks/3665) A runner that runs as a service slot now keeps the running view's board and serves its view on a Unix socket. `sak-runner --attach` shows that view in a terminal on the host, over ssh too, and sends it keys. ## What it delivers - **The slot keeps the board.** A slotted runner copies each chunk it prints to the runner log unchanged, and feeds the same lines to the board. It listens on `~/.sak-runner/view.<slot>.sock`, which only its user can reach (mode 0600). It opens no network port. - **`sak-runner --attach [--slot a|b]`** connects to the slot the configuration's `slot` line names, or to the one given. It puts its terminal in raw mode, sends its size, each resize and its input, and writes what the runner draws. The pages and keys are the ones a terminal runner shows, drawn by `runner/view.sx`. The runner side is a std.tui peer session. - **Leaving.** `d detach` is in the footer of every page. `d`, ctrl-c, the attach's input ending, or its connection dropping end the attach and leave the runner as it was. - **`q`** asks how to stop, as at a terminal. In an attach, the box says `launchd does not restart slot a once it stops` (systemd on Linux). The farewell line goes to the attach and to the runner log. - **Several views at once.** Attaches are open together, each at its own size, with its own page, selection, log scroll and Flows follower. `runner/view.sx` is split into `Host`, one per runner (the board, the captured stdout, settling and the stop), and `View`, one viewer. The board wakes every open view. - **Keys wait at the slot.** What an attach types waits at the slot and goes to its view as the view's input takes it. Meanwhile the slot keeps sending the attach what the view draws. Once the view has ended, keys typed to it reach nothing. - **Hand-over.** Across an update's hand-over, an attach stays on the old slot while it finishes its steps. That slot then tells it the slot it handed over to. The attach says `sak-runner: slot a handed over to slot b` and attaches to slot b. - **No runner.** With no runner listening, `--attach` prints `sak-runner: no runner listens on <socket>` and exits 1. - **The wire** (`runner/attach.sx`). Each message is a kind byte, a 4-byte length and a payload. The attach sends `z` (size) and `k` (keys). The slot sends `o` (screen bytes), then `d` (detached), `s` (stopped, saying how) or `h` (handed over, naming the slot). - **Docs.** `runner/README.md` has an Attaching section after The console, and the flag paragraph and Tests describe `--attach` and `tests/runner_attach.sx`. ## Tests `tests/runner_attach.sx` serves sak in process and runs a slotted runner in process, with its stdout pointed at a file that stands in for the runner log. - **Attaches over the socket:** - the first frame is at the attach's size (header on row 1, footer on row 30); - a resize to 24×90; - ↓ moves the selection; - a second attach at 20×80 opens beside the first, with a selection of its own; - `d`, ctrl-c and input ending each detach, and the runner takes a step and holds its steps and channel after; - more keys than a view's input holds, typed while the view shows: the view draws on and takes each key, and `q` after them shows the stop box; - a size sent once a view's session closed, while the view waits for its Flows page's call to sak, reaches nothing, and more keys than a view's input holds, typed then, still leave the attach to be told it detached; - the stop box's launchd line; - an attach is told `h b` when the runner hands over, and `s stopped · …` when it is stopped from an attach. - **Other checks:** the socket's mode is `srw-------`, and the runner log keeps the console lines. - **`dist/sak-runner --attach` on a pseudo-terminal:** it shows the view, sends ↓, redraws on a resize of its own terminal, follows the hand-over to a stand-in for slot b, and detaches there (exit 0). - **No runner:** with no runner listening, it prints `no runner listens on …` and exits 1, for `--slot a` and for the configuration's `slot=b`. `tests/runner_handover.sx` holds `sak-runner --attach --slot a` on a pseudo-terminal across the hand-over between real slot processes under its systemd stand-in. The attach follows slot a to slot b, shows slot b's view and detaches there with exit 0. **Fail-before.** At master ce44f520 the test does not build: `cannot read import 'runner/attach.sx'`. Mutation arms of 895dba9f, each a `git archive` arm with `dist/sak-runner` rebuilt. The first failing check is the pinned one; later failures in an arm cascade from it: | mutation | failing checks | | --- | --- | | session opened at the default size | 12, first "the first frame is at the attach's size" | | size messages after the first ignored | 9, first "a resize draws the view at the new size" | | `d` not detaching | 1, "d detaches" | | no `chmod 0600` | 1, "only the slot's user reaches its socket" | | no copy to the runner log | 1, "the slot's lines still reach its log, as they were" | | `finish` dropping the slot handed over to | 3, the hand-over checks | | the client not following `h` | 2, "sak-runner --attach says slot a handed over to slot b …" and its detach | The existing tests that run a slotted or terminal runner pass at 895dba9f, run through `tests/run.sh`: `runner_handover` (163 s), `runner_launchd` (100 s, real launchd), `runner_view` (66 s), `runner_install` (12 s). ## Review answers - **A size sent after a view ended reached its freed session.** When a view ended, its thread closed the attach's peer session, which frees the size a resize stores into and closes the session's signal pipe. Only then did it wait for the view's Flows follower to return from its call to sak. Meanwhile the relay still read the attach's messages, and each size made it resize the closed session: the size went into freed memory, and a byte went to whatever file then held the number of the session's signal pipe. Each attach now has a mutex and a `closed` flag. The view's thread sets the flag before it closes the session, and the relay resizes only while the flag is clear. - Regression, in `tests/runner_attach.sx`: the in-process sak's dispatch holds the GETs of `/api/flow-runs` while the test asks. An attach opens its Flows page, so its follower's call is held, then sends `d`. The test waits for the number of the session's signal pipe to be free, takes it for a pipe of its own (`F_DUPFD_CLOEXEC`), sends a size, and checks that its pipe received nothing. Then it lets the call go and checks that the attach is told it detached. - Fail-before: with the head's tests over the earlier `runner/attach.sx`, 4 runs of 4 failed "a size the attach sends then reaches nothing", and each process then died of SIGTRAP (exit 133) after the freed size was written. With the fix, `runner_attach` passed 7 runs of 7. - **The hand-over through `hold()` had no test.** `tests/runner_handover.sx` now holds `sak-runner --attach --slot a` on a pseudo-terminal across the hand-over between real slot processes. The client is the installed version file, so its command names the scene's directory. Once the old slot's process exits, the attach says `sak-runner: slot a handed over to slot b` and shows slot b's view, and `d` there ends it with `sak-runner: detached from slot b`, exit 0. - Fail-before: an arm without `hold()`'s line that names the slot handed over to, with `dist/sak-runner` rebuilt there, printed `RUNNER HANDOVER FAIL (3 checks)`: "the attach says slot a handed over to slot b", "... and shows slot b's view" and "... whose detach ends the attach". The head passed (`RUNNER HANDOVER OK`, 158 s). - **Keys typed while a view was ending held the relay for good** (review 1975, F1). The relay wrote each `k` message to the view's input pipe with a blocking write. Once the view stopped reading, a burst larger than the pipe held the relay in that write. The attach was never told it detached, and the runner's stop and hand-over waited on that relay forever. The same write also wedged a view that was still showing. The relay is the only reader of what the view draws. While it waited for the view to take keys, a view that redrew on each key filled its screen pipe and then stopped taking keys. - Fix: keys now wait at the slot in a queue. The relay hands them to the view's input without blocking, only while the pipe has room, and meanwhile keeps reading the view's screen and the attach's messages. When the view ends, its thread closes its input right after closing its session, so keys handed after that fail at once and the relay drops them. - Regressions, in `tests/runner_attach.sx`. Both send one burst that is more than the view's input pipe holds: 8192 keys the view redraws on one by one, then a 128 KiB bracketed paste. - While the view shows: after the burst, `q` shows the stop box. - In the held-follower scene, after `d`: once the follower's call returns, the attach is told it detached. - A wedged relay would hang the test at the runner's stop. So when either check fails, the test prints `RUNNER ATTACH FAIL` and exits 1 within its 30 s wait. - Fail-before and pass-after, from `git archive` arms of 84eb16bb with the new test: | arm | result | | --- | --- | | 84eb16bb's `runner/attach.sx` | 1 check failed, "… q after them shows the stop box", exit 1 in 35 s | | 84eb16bb's `runner/attach.sx`, test without the live burst | 1 check failed, "once the follower's call returns, the attach, which typed more meanwhile than a view's input holds, is told it detached", exit 1 in 37 s | | the review's fix alone: the view's thread closes its input, but the relay's write still blocks | 1 check failed, the live one | | this fix | `RUNNER ATTACH OK` in 8 s, then 3 runs of 3 OK, 5 s each | - The review's probe sends a 256 KiB `k` message after "a size the attach sends then reaches nothing". A second probe sends 256 KiB while the view shows. At 84eb16bb, each failed its check and then hung until `timeout` killed it at 150 s. With the fix, both pass. - Not pinned: an arm that leaves the view's input open until the relay closes it after the view's thread ends also passes. The queue alone keeps the relay from blocking. Closing the input at once only stops keys from queuing for a view that takes no more. The branch also merges master bc8ec978 (#3981) cleanly, so the gates below test the tree Merge would land. ## Gates at the head At 587a7d1e, with master bc8ec978 merged in (master has not moved since): - `full`: gate run 995, passed: - `tests/run.sh`: 71 files in 296 s, with `runner_handover` 159 s and `runner_attach` 7 s; - vitest: 448 passed; - release.sh and step9; - the deploy contract; - Playwright main config: 777 passed, 1 skipped; - Playwright runner config: 10 passed. - `fast`: gate run 992, passed. It ran on the same tree before the commit, so it is recorded at 84eb16bb. Earlier rounds: at 84eb16bb, `fast` 984 and `full` 988 passed; at 895dba9f, `fast` 964 and `full` 967 passed. ## Left open On the way, the pinned sx (af8455c3) rejected a `match` that has a `return` or `continue` arm, an arm that discards a `bool` call's value, and an empty arm. The error is `cannot coerce a value of type 'void' to 'bool'`. No SX task covered it; the reproduction is in the step's summary for filing. The code here writes terminal output through `FdWriter`, as `tui.Session` does, so it does not meet that case.
agra merged commit 6e82e8d993 into master 2026-10-30 20:35:04 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
sx/sak!194
No description provided.