# G-038 Piped-to-interactive restart continues the same session (context preserved) Status: proposed Scope: src/lib/app-punkshell/punkshell.tcl (eof-restart handover), src/modules/punk/repl-999999.0a1.0.tm (eof-restart done-mode that skips codethread teardown), src/modules/punk/repl/codethread-999999.0a1.0.tm (as touched) Goal: when piped stdin ends and app-punkshell opens the interactive console shell, the session continues rather than restarts - the piped phase's codethread/code interp survives (variables, procs, namespaces, loaded packages, cwd, ::errorInfo/::errorCode) and only the input channel and repl reader are renewed - so `'set ::jjj blah' | shell` leaves ::jjj inspectable at the prompt, replacing today's silent fresh-session swap (least-surprise violation; motivating transcripts in the detail file) and obsoleting the standalone piped-error-record mechanism this goal described before its 2026-07-08 rework. Acceptance: after `'set ::jjj blah;error xxxx' | shell` reaches the interactive prompt: `set ::jjj` returns blah; the xxxx message, errorInfo traceback and errorCode are inspectable (via preserved ::errorInfo if nothing in the handover overwrites it, else a documented record - the chosen mechanism noted in the detail file); a proc defined and a package required during the piped phase remain available; a cwd change from the piped phase persists; a one-line notice at the restart says the session continued from piped input (mentioning the error when the last piped command errored, quiet otherwise); after the restart a terminal query from the code interp succeeds (e.g. `help console` runs its cursor-position test instead of being refused by the stale settled can_respond=0 anchor); interactive exit/quit teardown afterwards is clean (the G-036 regression harness still passes); PUNK_PIPE_EOF policy semantics and the script subcommand are unchanged; verified on both Tcl generations. ## Context Reworked 2026-07-08 from a narrower draft ("piped-phase errors inspectable in the restarted interactive shell") after the user identified the general least-surprise violation: - `'set ::jjj blah' | punk902z` -> interactive prompt -> `set ::jjj` fails: no such variable. - `'puts test;error xxxx' | punk902z shell` -> `set errorInfo` at the prompt shows unrelated boot noise (`can't unset "timer"` from tcllib virtchannel_core's `catch { unset timer }`, stamped during the fresh repl::init's fifo2 log-tee construction) instead of xxxx. Cause: app-punkshell's piped-EOF handling (the path that actually runs for pipes, since a pipe is not tcl_interactive) rebuilds the shell from scratch - `do_shell` runs a fresh `repl::init` (new codethread + code interp) and `repl::start`. Everything the piped phase did in the code interp - variables, procs, namespaces, package requires, cwd, errorInfo - is silently discarded. The user's mental model is "my session continues interactively", i.e. the same expectation a tclsh user has typing those commands then continuing. A third symptom class (user report 2026-07-08, mechanism proven same day - see Notes for the harness): after the restart, `help console` and EVERY terminal query (get_cursor_pos, dec_get_mode, test_char_width, ...) fail with "console object for 'stdin' 'stdout' is settled as unable to respond (can_respond=0) - refusing to emit query". Chain: 1. During the piped phase the anchored `default` opunk console ({stdin stdout}) settles can_respond=0 - correct at the time, stdin is a pipe (punk::console 0.7.1 settling). 2. At EOF, punkshell.tcl (~l.501-517) opens CONIN$ as a NEW channel (name `file...`) and SetStdHandle's the process stdin handle, but never closes or replaces the repl thread's Tcl channel `stdin` - it remains the exhausted pipe (chan eof = 1), and the anchored default console instance still references {stdin stdout}. 3. The fresh codethread's code interp DOES get a real console stdin (new thread std channels initialise from the process handles), but its queries route (G-007) to the console-owning repl thread, where the stale anchor refuses them. 4. Recovery is impossible without re-anchoring: `settle_can_respond -force default` in the owner thread honestly re-settles 0 via layer 1 (input at eof) because the object's input IS a dead channel. The stale anchor, not just the stale verdict, is the defect - exactly the gap flagged by the review comment at repl-999999.0a1.0.tm ~l.3168. Precedent in the codebase: the repl-internal restart path (`repl::reopen_stdin`) is context-preserving by design - it reuses the live codethread and restarts the repl on the reopened console channel. Its problem is structural, not conceptual, and this goal should own that path's fate (retire it in favour of the caller-driven restart, or fix it), because it DOES run in a real scenario: console EOF at an interactive prompt (ctrl-Z/ctrl-D style) with tcl_interactive true. Latent races mapped during the G-036 investigation (conversation-level analysis, recorded here since this goal's implementer needs it): - The repl EOF branch schedules TWO independent continuations: `after 1 repl::reopen_stdin` (timer) and `after idle {set ::repl::done {eof }}` (idle). Which fires first is nondeterministic (depends whether ~1ms elapses before the event loop drains to idle). - reopen_stdin calls repl::start directly from inside the timer callback, i.e. NESTED inside the still-active outer repl::start's vwait. Both frames vwait the same shared `::repl::done` namespace variable - a single write (e.g. from exit/quit, or the stale idle eof-write landing late) satisfies both traces and unwinds both frames. - Both frames share the `codethread`/`codethread_cond` namespace variables: whichever frame's teardown runs first cancels the codethread and empties the variables; the second frame's teardown then runs `tsv::unset` on a missing array (caught, warning) and `thread::cancel ""` (uncaught error). - The reopened CONIN$ channel is adopted into the stdin std-channel slot, so the outer frame's teardown `chan event stdin readable {}` strips the readable handler off the nested repl's live input channel. - The stale idle done-write can also be absorbed harmlessly during nested startup (sync thread::send services events), making the whole ensemble timing-dependent. The caller-driven design eliminates the nesting (single repl::start frame at a time, done state owned per invocation), and should cover the console-EOF restart as well as the piped-EOF restart so reopen_stdin can be retired rather than left as a second, racy path. ## Approach The caller-driven restart already identified during G-036 as the clean fix direction: 1. `repl::start` gains an eof-restart outcome that returns WITHOUT tearing down the codethread (today's teardown at the end of repl::start unconditionally cancels it - the exit/quit/eof done-values must branch). 2. app-punkshell's eof branch, on deciding to go interactive (existing PUNK_PIPE_EOF policy unchanged), opens CONIN$/SetStdHandle as today but then calls `repl::start` again on the new channel WITHOUT `repl::init` - same codethread, same code interp, session continues. (do_shell currently bakes `repl::init` into the script it runs - the restart invocation needs a variant.) 3. What renews vs persists: - renewed: input channel + repl reader, tcl_interactive, prompt state; the anchored default console object must be rebound to the new channels (known stale-after-reopen comment at repl-999999 ~line 3168; adjacent to G-001/G-011 console-object work). This is now a proven user-visible defect, not just hygiene (see Context: post-restart `help console`/terminal-query failures). The rebind needs BOTH halves: re-point the anchored default instance's {in out} to the renewed input channel AND unsettle can_respond (set -1, or settle_can_respond -force after the rebind) so the first interactive query re-probes against the real console. Design choice to record when implementing: either close the dead stdin before opening CONIN$ so the new channel lands in the stdin std-channel slot (spec {stdin stdout} becomes correct by itself, smallest console-side change, but mind readers/stacks still referencing stdin during the handover), or keep the current open-then-SetStdHandle order and add an explicit console re-anchor step in the handover. - persists: everything in the codethread/code interp, including `after` timers and fileevents registered by the piped script (a feature, but note it as a behaviour change - a piped script's scheduled work now keeps running into the interactive session; the notice line mitigates surprise). - decide: repl-side editbuf/history fresh or carried (fresh is acceptable; record the choice). 4. Error inspectability likely falls out free: with no re-init there is no boot-noise re-stamping, so the piped phase's ::errorInfo is simply still present. If some handover step does overwrite it, fall back to the superseded draft's mechanism: capture at the repl's error-reporting site (the lowercase p% prompt path) into a punk-owned record (e.g. ::punk::last_pipeline_error) surfaced with the notice line. Note regardless: bare ::errorInfo in a long-lived shell is always subject to later caught-error stamping by internals - the notice line should show the error message itself so the user need not race to inspect. Subshell continuity (user note 2026-07-08, support if practical - not in acceptance): once per-subshell configurations exist (G-008/G-009), a user might reasonably run `'subshell ' | ` and expect the interactive prompt to be IN that subshell, with `quit` unwinding to the initial shell. This does NOT fall out of this goal's changes automatically: continuity preserves code-interp state, but the subshell stack is repl control flow - under the current synchronous nested model the piped `subshell` command nests a repl on the same stdin, and pipe-EOF unwinds the whole subshell stack before the restart decision is made, so the prompt would land in the top shell (with the subshell's interp-side effects intact but the subshell exited). Options: (a) within this goal - capture the active subshell stack at EOF and re-enter it on restart (fights the nesting model); (b) the natural home - G-002's non-nested subshell architecture with console targeting, where pipe-EOF becomes "reattach the reopened console to the innermost active subshell" and quit unwinds normally. Record the outcome here; if deferred to G-002, this note is the cross-reference. Related idea (separate concern, launcher surface): a `-subshell ` flag on the `shell` subcommand as the primary way to start inside a named subshell (candidate for the G-032 punk::args launcher declarations when subshell configs exist). Risks / interactions: - The teardown-mode branching touches exactly the machinery mapped during G-036 (shellthread worker lifecycle, shellfilter::run tail, thread teardown ordering) - the G-036 regression harness is the safety net and is in this goal's acceptance. - shellfilter::run/do_shell wrap the repl in tee stacks per invocation; the restart variant must not double-stack or leak them across the handover. ## Alternatives considered - Standalone piped-error record without session continuity (this goal's original draft) - superseded by the rework: it fixed one symptom (error inspection) while leaving the general context loss (::jjj case) in place, and continuity likely obviates the record. - Keeping fresh-session semantics but documenting them + printing a "new session" notice - rejected as the primary outcome (documentation doesn't restore the lost work), though the notice-line idea is retained within the continuity design. ## Notes - Related: G-036 detail (restart machinery map, teardown races, regression harness); G-002 (non-nested subshell architecture - same family of repl lifecycle decoupling); G-031 (componentized boot); G-001/G-011 (console object rebinding on reopen); G-015 (script subcommand unaffected - it never drops to interactive). ### Post-restart console-query harness (2026-07-08) Hidden-console repro/regression check for the stale-console-anchor symptom, reusable when implementing the rebind (pattern per the G-036 batch harness). Pipe a file containing `puts test` plus a `repl eval` that schedules a post-restart probe (repl-interp timers survive the restart; codethread timers do not), run under a hidden console so the CONIN$ restart occurs, and have the probe write its findings to a file: ``` puts test repl eval {after 6000 { thread::send -async $::repl::codethread {interp eval code { set f [open $::env(TEMP)/helpconsole_diag.txt w] puts $f [expr {[catch {punk::console::get_cursor_pos} r] ? "ERROR: $r" : "OK: $r"}] close $f }} after 15000 {::punk::repl::exit 0} }} ``` Launch: `Start-Process cmd.exe -WindowStyle Hidden -ArgumentList '/c',' src shell < > 2>&1'`. Pre-fix signature: ERROR "...settled as unable to respond (can_respond=0) - refusing to emit query". Post-fix: the probe should emit a query (in a real terminal it succeeds; under a hidden conhost a query timeout is acceptable - the assertion is that the refusal is gone and repl-thread `chan eof stdin`-style staleness no longer applies to the console object's channels). State observed pre-fix, for reference: repl thread post-restart has `stdin` eof=1 (the exhausted pipe) alongside the new `file...` CONIN$ channel; the code interp's stdin is a live console channel (utf-16, -inputmode present); anchored instances known to the repl thread: `default` (anchored to the dead pair). - Archived-goal references in this file: G-001 achieved 2026-07-11 (goals/archive/G-001-pluggable-console-backends.md);G-007 achieved 2026-07-05 (goals/archive/G-007-console-location-transparency.md);G-015 achieved 2026-07-07 (goals/archive/G-015-script-subcommand-piped-stdin.md);G-036 achieved 2026-07-08 (goals/archive/G-036-tcl9-udp-console-worker-wedge.md).