5.7 KiB
G-163 unix tty exit-path output drain: no tail loss from shared-description O_NONBLOCK
Status: proposed Scope: src/modules/punk/repl-999999.0a1.0.tm (exit/eof done paths, reader-window blocking-restore discipline); src/modules/punk/console-999999.0a1.0.tm (drain helper, -blocking 0 window sites); src/lib/app-punkshell/punkshell.tcl (final-exit seam); ARCHITECTURE.md (Process exit and shutdown section); src/tests/shell/testsuites/punkexe/ (unix-pty-gated drain pin) Goal: punkshell sessions on unix ttys do not silently lose queued stdout/stderr output at exit: the exit paths restore blocking on the shared tty file description (undoing the O_NONBLOCK that stdin's non-blocking read windows set underneath Tcl's per-channel view) and drain pending channel output with a bounded best-effort wait before teardown - closing the tclIO behaviour where a large emission near exit is silently truncated (background-flush-scheduled makes 'flush' a no-op and TclInExit discards EAGAIN'd buffers). Acceptance: on a linux/WSL pty with a rate-limited reader, a punkshell kit session that emits a numbered-line block larger than the pty buffer and immediately exits delivers the block complete (all markers present in the captured stream), where the same harness with the guard absent demonstrates tail loss (pre-fix baseline recorded in this file); in that scenario the exit path reaches 'chan pending output' 0 on stdout and stderr before teardown; with a fully-stopped reader the session still exits within the guard's declared bounded timeout (best-effort drain, never a hang); existing windows exit/quit behaviour and the piped-eof handling path are unchanged (relevant punkexe pins stay green); the mechanism and guard are documented in ARCHITECTURE.md's Process exit and shutdown section; a unix-pty-gated pin covers the complete-delivery and bounded-timeout cases (constraint-skipped where no unix pty is available).
Context
On a unix tty, fd 0/1/2 typically share one open file description, so the repl's and punk::console's read-window 'chan configure stdin -blocking 0' sets O_NONBLOCK on stdout/stderr underneath Tcl's per-channel view (stdout still reports -blocking 1). tclIO.c then behaves as follows (verified 2026-08-03 against the TEMP_REFERENCE/tcl9 checkout; the code comments acknowledge the dup'd-tty case): a write hitting EAGAIN queues with BG_FLUSH_SCHEDULED; from then on synchronous 'flush' returns success without writing (drain needs event-loop writable events); and during exit the EAGAIN path does not reschedule - queued output is discarded at finalization.
Measured (stock tclsh 8.6.14, WSL pty, throttled reader, 2026-08-03): blocking control delivered 5000/5000 lines; with stdin -blocking 0, /proc/self/fdinfo/1 flags went 02 -> 04002, 'flush' claimed success with ~498KB pending ('chan pending output'), and exit delivered 161/5000; a 6s event-loop grace still lost the final ~22KB. The window flickers with every repl read cycle, so the exposure is a large emission while a window is open followed by prompt exit (scripted sessions, error exits, quit right after a big table). WSL's slow console pipeline is the amplifier; windows console channels do not share flags this way and are unaffected. Each thread's std channel instance has its own queue, so worker threads that stop servicing their event loop strand queued output the same way (the thread dimension - G-090 territory).
Full investigation record: session memory 2026-08-03 (punkshell-unix-repl-output-loss); distinct from the deterministic tee_to_pipe clear defect fixed as shellfilter 0.2.5.
Approach
- A drain guard on the true-exit path: restore stdin -blocking 1 first (Tcl's fcntl clears O_NONBLOCK on the shared description, so queued data drains with blocking writes on the next writable event), then service events until 'chan pending output' is 0 on stdout/stderr or the bounded timeout expires.
- Home for the helper: punk::console (owns raw/mode arbitration and the window sites); invoked from the repl/app final-exit seam - after the G-038 eof-restart decision point, so a restarting session never pays or interferes with the drain.
- The pin drives a kit on a pty with a rate-limited consumer (the 2026-08-03 harness pattern: script(1) + throttled reader) and a stopped consumer for the timeout case.
Alternatives considered
- Dedicated /dev/tty output channel (own file description, immune by construction) - deferred: larger surface change to output routing; intersects G-011's err-channel design and would be better decided there.
- Do nothing until raw-default lands - rejected: G-013 widens the non-blocking window substantially, increasing exposure; the guard should exist first.
Notes
- Related: G-038 (piped-to-interactive restart) - shares the exit/eof seam; the drain applies only to true exit and must leave the eof-restart handover untouched.
- Related: G-013 (raw mode as default input mode) - raw mode keeps stdin non-blocking for much longer, widening this hazard's window; this guard is a prerequisite-grade hardening for that goal.
- Related: G-011 (per-console err channel) - stderr shares the tty description today; the deferred dedicated-channel alternative belongs to that design space.
- Related: G-090 (shellfilter/shellthread lifecycle audit) - worker threads stranding their per-thread channel queues at finalize is the thread dimension of this hazard; G-036 (achieved - see goals/archive/G-036-tcl9-udp-console-worker-wedge.md) is the adjacent exit-wedge lineage.
- Overlap survey 2026-08-03: goals_xref paths repl + console modules + ARCHITECTURE.md; mechanism-relevant intersections named above; the remaining repl/console-scoped proposals (G-002/G-003/G-008/G-009/G-010/G-014/G-042/G-043/G-044) overlap only by module and are judged non-overlapping for this goal's mechanism.