ba2f646a
docs: decide one read per readiness, and the seal after the last rollback
a73x 2026-08-27 13:22
Commit message
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -7061,3 +7061,47 @@ share a daemon with an earlier group and are refused by name. Running groups | |||
| 7061 | alone found two defects the full suite could not: the leak sweep's | 7061 | alone found two defects the full suite could not: the leak sweep's |
| 7062 | vacuous-green guard was keyed to daemon one's capture, and two socket names | 7062 | vacuous-green guard was keyed to daemon one's capture, and two socket names |
| 7063 | were declared in the wrong group. | 7063 | were declared in the wrong group. |
| 7064 | |||
| 7065 | ## 2026-08-27 — the daemon reads a frame in one read, and seals what an upgrade adopted | ||
| 7066 | |||
| 7067 | **One peer that stopped mid-frame parked the whole daemon.** Found on a live | ||
| 7068 | wall: `muxd stats` came back rc=124, and the daemon's stack said | ||
| 7069 | `unix_stream_read_generic` — one client had written part of a frame and gone | ||
| 7070 | quiet, and `proto.readFrame`'s loop was still waiting for the rest, with every | ||
| 7071 | other session's output, every await and every attach behind it. Killing that | ||
| 7072 | one client freed everything instantly. One byte on a fresh daemon reproduces | ||
| 7073 | it. The daemon side now does exactly one `read()` per readiness into a | ||
| 7074 | per-connection buffer and peels whole frames off it with `protocol.delimitFrame` | ||
| 7075 | through a single `Server.takeFrame`; a short read is just a buffer that is not | ||
| 7076 | a frame yet. Observers gained the same buffer, and promotion moves it with the | ||
| 7077 | fd, because the write that carries an attach usually carries the next frame | ||
| 7078 | behind it. | ||
| 7079 | |||
| 7080 | **The client keeps its blocking read on purpose.** There, one thread owns one | ||
| 7081 | transport and has nothing else to do while a frame is in flight, so blocking | ||
| 7082 | until the frame is whole is the correct shape — the entry on `Conn.awaitFrameFd` | ||
| 7083 | already argues that half, and this changes nothing about it. The rule is about | ||
| 7084 | who is multiplexed, not about which read is safer. | ||
| 7085 | |||
| 7086 | **What is still blocking on the daemon, and why it is filed rather than fixed.** | ||
| 7087 | Observer replies go out through `writeFrame` on the raw fd. Only | ||
| 7088 | `debug_dump --vt` can plausibly exceed the ~200 KiB socket send buffer, and only | ||
| 7089 | for a local peer that asks and then stops reading. Real, small, and not the | ||
| 7090 | thing that was wedging walls. | ||
| 7091 | |||
| 7092 | **An upgrade adopted fds and never put the flag back.** `execUpgrade` clears | ||
| 7093 | `FD_CLOEXEC` on the listener, the QUIC socket, every pty master, every agent | ||
| 7094 | listener and the manifest memfd so they survive the exec. Nothing re-set it, so | ||
| 7095 | every shell spawned after an upgrade inherited the lot: the listener on fd 3, | ||
| 7096 | an agent socket on 5, a pty master, and one `memfd:mux-upgrade` per survived | ||
| 7097 | upgrade — and that memfd carries the QUIC key bytes. `Server.sealAdoptedFds` | ||
| 7098 | now re-seals them, and the memfd is closed rather than sealed, having been read. | ||
| 7099 | |||
| 7100 | **The seal is placed after the last rollback exit, not in `initFromManifest`.** | ||
| 7101 | Rollback re-execs the OLD binary with those same fds, and a sealed fd does not | ||
| 7102 | cross that exec; the first placement broke the rollback e2e leg with a panic. | ||
| 7103 | So the call lives in `resumeRun`, past the point where rollback can still fire. | ||
| 7104 | The witness asks the OS, not the daemon: `/proc` for the post-exec shell's fd | ||
| 7105 | table (`socket:`, `memfd:`, `/dev/ptmx`) and the daemon's own for | ||
| 7106 | `memfd:mux-upgrade`. The QUIC socket's seal is unpinned — no e2e daemon in that | ||
| 7107 | leg runs `--quic`; filed. | ||