Browse Source

punk::console 0.7.1: idle-reader hostage guard - fail fast instead of timeout + phantom input

On a tcl 8.6 windows console in cooked (line) mode with a readable handler
armed on stdin - the idle-at-a-line-mode-prompt condition, e.g. a query
fired from an after-script or worker thread while the shell waits for
input - the 8.6 channel driver has a blocking cooked-mode ReadConsole
parked (arming posts it; driver reads sample the console mode at issue
time), so a terminal query's response is swallowed until Enter and then
leaks to the line reader as phantom input. This is the one remaining
hostage window after the 0.7.1 detection fixes and repl 0.2.2 read
discipline: it is the reader legitimately doing its job, so no repl-side
change can remove it (and line mode remains supported for user
scripts/mini-apps even once raw becomes the default).

get_ansi_response_payload now detects the condition before emitting
(cooked + no -inputmode key + twapi console handle + armed readable
handler) and refuses fast with errorcode
{PUNK CONSOLE QUERY HOSTAGE_COOKED_READ} - no emission, no ~500ms timeout,
no input corruption. Mid-command queries (repl reader disarmed) and raw
mode are unaffected. Best-effort by design: a parked read can outlive a
removed handler, so the guard catches the systematic case only. The G-007
routing wrapper now preserves the owner-side errorcode so brokered callers
can discriminate the refusal.

Tests: constraint-gated guard test in probes.test (engages on a real 8.6
console, self-skips on 8.7/9 via the -inputmode key and in piped runs);
ownerrouting.test passes with the errorcode-preserving wrapper. Full suite
at baseline (exec-14.3 only) under Tcl 9.0.3; probes suite also verified
under Tcl 8.6. Project version bumped to 0.2.2 with CHANGELOG entry per
the versioning policy (user-visible failure-mode change).

Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com
master
Julian Noble 1 week ago
parent
commit
28106ac4b2
  1. 4
      CHANGELOG.md
  2. 2
      punkproject.toml
  3. 27
      src/modules/punk/console-999999.0a1.0.tm
  4. 1
      src/modules/punk/console-buildversion.txt
  5. 30
      src/tests/modules/punk/console/testsuites/console/probes.test

4
CHANGELOG.md

@ -5,6 +5,10 @@ The latest `## [X.Y.Z]` header must match the `version` field in `punkproject.to
Entries are newest-first; one bullet per notable change. See the root `AGENTS.md` Entries are newest-first; one bullet per notable change. See the root `AGENTS.md`
"Project Versioning" section for the bump policy. "Project Versioning" section for the bump policy.
## [0.2.2] - 2026-07-06
- punk::console: terminal queries fired while an 8.6-based shell waits at an idle line-mode prompt now fail fast with a discriminable errorcode (`PUNK CONSOLE QUERY HOSTAGE_COOKED_READ`) and emit nothing, instead of timing out (~500ms) and later corrupting input with the swallowed response (idle-reader hostage guard; raw mode and mid-command queries unaffected). Owner-routed queries preserve the owner-side errorcode.
## [0.2.1] - 2026-07-06 ## [0.2.1] - 2026-07-06
- `make.tcl projectversion`: new advisory subcommand verifying `CHANGELOG.md` matches `punkproject.toml` and warning if `src/` has commits since the last project-version bump. - `make.tcl projectversion`: new advisory subcommand verifying `CHANGELOG.md` matches `punkproject.toml` and warning if `src/` has commits since the last project-version bump.

2
punkproject.toml

@ -1,3 +1,3 @@
[project] [project]
name = "punkshell" name = "punkshell"
version = "0.2.1" version = "0.2.2"

27
src/modules/punk/console-999999.0a1.0.tm

@ -581,7 +581,10 @@ namespace eval punk::console {
try { try {
return [thread::send $route_owner [list ::punk::console::internal::get_ansi_response_payload -console $inoutchannels -expected_ms $expected -ignoreok $ignoreok -return $returntype -passthrough $passthrough $query $capturingendregex]] return [thread::send $route_owner [list ::punk::console::internal::get_ansi_response_payload -console $inoutchannels -expected_ms $expected -ignoreok $ignoreok -return $returntype -passthrough $passthrough $query $capturingendregex]]
} on error {errM erropts} { } on error {errM erropts} {
return -code error "get_ansi_response_payload query routed to console-owning thread $route_owner failed: $errM" #preserve the owner-side errorcode (where the transport propagates it) so
#callers can discriminate, e.g. {PUNK CONSOLE QUERY HOSTAGE_COOKED_READ}
#from the idle-reader guard
return -code error -errorcode [dict get $erropts -errorcode] "get_ansi_response_payload query routed to console-owning thread $route_owner failed: $errM"
} }
} }
@ -611,6 +614,28 @@ namespace eval punk::console {
error "punk::console::get_ansi_response_payload input channel '$input' is closed or at eof - cannot receive terminal response" error "punk::console::get_ansi_response_payload input channel '$input' is closed or at eof - cannot receive terminal response"
} }
#tcl 8.6 windows console idle-reader hostage guard (fail fast, no emission).
#With the console in cooked (line) mode and a readable handler armed on the input
#channel, the 8.6 channel driver has a blocking cooked-mode ReadConsole parked
#(arming is what posts it), and driver reads sample the console mode at issue time -
#the raw cycle below cannot rescue it. Any response would be swallowed by that read
#until the user presses Enter, then leak to the line reader as phantom input
#(~500ms timeout now, input corruption later). This is the idle-at-a-line-mode-prompt
#condition: the repl disarms its reader during command dispatch, so mid-command
#queries are unaffected, and raw mode reads cooperatively and is exempt. Typical
#trigger: a query from an after-script or worker thread firing while the shell sits
#at a line-mode prompt. Best-effort by design: a parked read can outlive a removed
#handler, so this catches the systematic case, not every conceivable one.
if {![tsv::get punk_console is_raw] && $input eq "stdin" && $::punk::console::has_twapi} {
set guard_conf [chan configure $input]
if {![dict exists $guard_conf -inputmode] && ![dict exists $guard_conf -mode]
&& [chan event $input readable] ne ""
&& ![catch {twapi::get_console_handle stdin}]} {
return -code error -errorcode [list PUNK CONSOLE QUERY HOSTAGE_COOKED_READ]\
"punk::console::get_ansi_response_payload refusing to emit query on '$input': tcl 8.6 console is in cooked (line) mode with an armed readable handler - the driver's parked cooked read would swallow the response until Enter (query issued from an idle line-mode prompt?). Use raw mode, or issue queries while the reader is disarmed (e.g. during repl command evaluation)."
}
}
#chunks from input that need to be handled by readers #chunks from input that need to be handled by readers
upvar ::punk::console::input_chunks_waiting input_chunks_waiting upvar ::punk::console::input_chunks_waiting input_chunks_waiting

1
src/modules/punk/console-buildversion.txt

@ -1,6 +1,7 @@
0.7.1 0.7.1
#First line must be a semantic version number #First line must be a semantic version number
#all other lines are ignored. #all other lines are ignored.
#0.7.1 - new idle-reader hostage guard in get_ansi_response_payload: on a tcl 8.6 windows console in cooked (line) mode with a readable handler armed on stdin (the idle-at-a-line-mode-prompt condition - e.g. a query fired from an after-script or worker thread while the shell waits for input), the query now fails fast with errorcode {PUNK CONSOLE QUERY HOSTAGE_COOKED_READ} and emits nothing, instead of timing out (~500ms) and having the response swallowed by the driver's parked cooked ReadConsole until Enter then leaking to the line reader as phantom input. Mid-command queries (repl reader disarmed) and raw mode are unaffected; best-effort - a parked read can outlive a removed handler, so the guard catches the systematic case only
#0.7.1 - compound emit-then-query operations flush their emissions before querying: get_size_using_cursormove/get_size_using_cursorrestore (the far-corner move), test_char_width (positioning and the measured test emission) and test_string_cursor (alt-screen/move/erase). Under G-007 routing the position query may execute in the console-owning thread, whose flush acts on its own channel instance for the same OS handle - the caller's unflushed emissions then reach the terminal after the query measures, e.g. get_size returning 'columns 1' on tcl 8.6 where the -winsize shortcut is unavailable and the ANSI mechanism actually runs (pre-routing this was masked because emit and query shared one channel instance, so the query's flush pushed the emissions too). Error paths flush their cursor-restore emissions likewise. #0.7.1 - compound emit-then-query operations flush their emissions before querying: get_size_using_cursormove/get_size_using_cursorrestore (the far-corner move), test_char_width (positioning and the measured test emission) and test_string_cursor (alt-screen/move/erase). Under G-007 routing the position query may execute in the console-owning thread, whose flush acts on its own channel instance for the same OS handle - the caller's unflushed emissions then reach the terminal after the query measures, e.g. get_size returning 'columns 1' on tcl 8.6 where the -winsize shortcut is unavailable and the ANSI mechanism actually runs (pre-routing this was masked because emit and query shared one channel instance, so the query's flush pushed the emissions too). Error paths flush their cursor-restore emissions likewise.
#0.7.1 - ensure_object_integration hardening: returns 0 as a graceful no-op (without latching object_integration_done) when opunk::console is not loaded, instead of erroring into the nonexistent namespace; and on wiring the lifecycle callback it retro-registers ownership for anchors created in this interp before the wiring (previously 'package require opunk::console; opunk::console::create ...' with no intervening punk::console object operation left the console without a registered owner - fact-store share qualifiers then fell back to the calling thread and other threads could not address that console's facts). Retro-registration fills empty registry entries only; existing live registrations are preserved. #0.7.1 - ensure_object_integration hardening: returns 0 as a graceful no-op (without latching object_integration_done) when opunk::console is not loaded, instead of erroring into the nonexistent namespace; and on wiring the lifecycle callback it retro-registers ownership for anchors created in this interp before the wiring (previously 'package require opunk::console; opunk::console::create ...' with no intervening punk::console object operation left the console without a registered owner - fact-store share qualifiers then fell back to the calling thread and other threads could not address that console's facts). Retro-registration fills empty registry entries only; existing live registrations are preserved.
#0.7.1 - fix Tcl 8.6 windows console misdetection (one root cause, three sites): 8.6 console channels expose no -inputmode configure key, so guards that test for -inputmode/-mode classified a real 8.6 console as a pipe. A twapi console handle for stdin is now treated as definitive at all three sites (stdin only - pipe probes still never flip the process console; mintty-as-pipes has no console handle and falls through to the env heuristics). Site 1: get_ansi_response_payload's raw-cycling gate (0.1.5) skipped the raw cycle for 8.6 line-mode queries. Site 2: is_input_console_or_tty false negative made settle_can_respond's layer-2 heuristic settle a real 8.6 console as unable to respond. Site 3 (the line-mode query killer, introduced 0.1.2): input_at_eof took the pipe branch and performed its probe read on the drained console channel - a read on a drained 8.6 console makes the channel driver park a blocking cooked-mode ReadConsole that a later raw flip cannot cancel (verified empirically against clean tclkits 8.6.13/8.6.17: driver reads sample the console mode when issued, not when data arrives), and since get_ansi_response_payload calls input_at_eof immediately before its raw cycle, every 8.6 line-mode query's response was swallowed by that parked read until Enter (~500ms timeout + response leaking to the line reader as phantom input; also the cause of increased ANSI artifacts in outputs like 'help env' once 0.7.0 brokering made code-interp queries actually reach the terminal). #0.7.1 - fix Tcl 8.6 windows console misdetection (one root cause, three sites): 8.6 console channels expose no -inputmode configure key, so guards that test for -inputmode/-mode classified a real 8.6 console as a pipe. A twapi console handle for stdin is now treated as definitive at all three sites (stdin only - pipe probes still never flip the process console; mintty-as-pipes has no console handle and falls through to the env heuristics). Site 1: get_ansi_response_payload's raw-cycling gate (0.1.5) skipped the raw cycle for 8.6 line-mode queries. Site 2: is_input_console_or_tty false negative made settle_can_respond's layer-2 heuristic settle a real 8.6 console as unable to respond. Site 3 (the line-mode query killer, introduced 0.1.2): input_at_eof took the pipe branch and performed its probe read on the drained console channel - a read on a drained 8.6 console makes the channel driver park a blocking cooked-mode ReadConsole that a later raw flip cannot cancel (verified empirically against clean tclkits 8.6.13/8.6.17: driver reads sample the console mode when issued, not when data arrives), and since get_ansi_response_payload calls input_at_eof immediately before its raw cycle, every 8.6 line-mode query's response was swallowed by that parked read until Enter (~500ms timeout + response leaking to the line reader as phantom input; also the cause of increased ANSI artifacts in outputs like 'help env' once 0.7.0 brokering made code-interp queries actually reach the terminal).

30
src/tests/modules/punk/console/testsuites/console/probes.test

@ -158,6 +158,36 @@ namespace eval ::testspace {
1\ 1\
] ]
#The idle-reader hostage guard only applies where chan configure exposes no -inputmode
#key (tcl 8.6 windows console) - on 8.7/9 consoles this constraint self-skips.
tcltest::testConstraint win_console_86_cooked [expr {
"windows" eq $::tcl_platform(platform)
&& $::punk::console::has_twapi
&& ![catch {twapi::get_console_handle stdin}]
&& ![dict exists [chan configure stdin] -inputmode]
&& ![tsv::get punk_console is_raw]
}]
test query_hostage_guard_armed_reader {a terminal query on a cooked 8.6 console with an armed stdin readable handler fails fast without emitting (idle-prompt hostage condition)}\
-constraints win_console_86_cooked\
-setup [string cat $common {
set prior_handler [chan event stdin readable]
chan event stdin readable {set ::probes_dummy_readable 1}
}]\
-body {
set status [catch {punk::console::get_cursor_pos} msg opts]
lappend result $status
lappend result [dict get $opts -errorcode]
}\
-cleanup {
chan event stdin readable $prior_handler
unset -nocomplain ::probes_dummy_readable
}\
-result [list\
1\
{PUNK CONSOLE QUERY HOSTAGE_COOKED_READ}\
]
test input_at_eof_stdin_windows_console {input_at_eof on a real windows console stdin returns 0 via the console guard - the pipe-branch probe read must not run (on tcl 8.6 a drained-console read parks a cooked ReadConsole that swallows subsequent query responses)}\ test input_at_eof_stdin_windows_console {input_at_eof on a real windows console stdin returns 0 via the console guard - the pipe-branch probe read must not run (on tcl 8.6 a drained-console read parks a cooked ReadConsole that swallows subsequent query responses)}\
-constraints win_console_twapi\ -constraints win_console_twapi\
-setup $common\ -setup $common\

Loading…
Cancel
Save