Browse Source

punk::console 0.7.0: G-007 slice 2 - choke-point brokering of terminal queries to the console-owning thread

A terminal query (internal::get_ansi_response_payload and every query proc
layered above it - get_cursor_pos, dec_get_mode, ...) on the process-default
console {stdin stdout} issued by a thread that is not the registered owner is
now forwarded whole to the owning thread via synchronous thread::send, so the
query queueing, raw-mode cycling, cooperative reader handling
(input_chunks_waiting) and settled can_respond gating all execute in the
owner's context. Owner-side errors propagate to the caller with routing
context. Routing happens before the local can_respond gate deliberately: a
non-owner context's anchored view of the default console may be unsettled
while the owner's is settled.

New internal::console_route_owner makes the decision: routing applies to the
default console pair only - non-std channel names are thread-local, so an
{in out} pair spec always names the calling thread's own console and operates
locally (a console constructed and owned by code-interp/worker code
round-trips nowhere). Unregistered, owner==caller and dead-owner
(liveness-validated) cases also operate locally, preserving single-interp
behaviour exactly.

The synchronous send relies on the owner servicing events while the caller
blocks - the property the repl-installed vt52/colour/mode aliases already
depend on. Those aliases are unaffected: a call arriving in the owner
resolves to owner==self and takes the local path, so no double-hop and no
ping-pong.

Tests: new ownerrouting.test covers the routing decision (unregistered/self/
other-live-thread/non-default-pair/dead-owner) and the transport (recorder in
a worker-thread owner: args marshalled intact, execution in the owner thread,
query procs above the choke point inherit the routing, owner-side error
propagation). Full suite passes (baseline exec-14.3 only).

G-007 remains active: the remaining acceptance verification is interactive -
a terminal query from a live punk session's code interp against the default
console (cooperating with the repl reader), which needs a real responding
terminal.

Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com
master
Julian Noble 1 week ago
parent
commit
6314312aa4
  1. 1
      src/modules/punk/AGENTS.md
  2. 52
      src/modules/punk/console-999999.0a1.0.tm
  3. 4
      src/modules/punk/console-buildversion.txt
  4. 125
      src/tests/modules/punk/console/testsuites/console/ownerrouting.test

1
src/modules/punk/AGENTS.md

@ -32,6 +32,7 @@ Source of truth for all modules under the `punk::*` namespace. This is the prima
- punk::console emit-side functions (the `punk::console::ansi::*` emit wrappers, mouse/paste toggles, `vt52`, `set_tabstop_width`, `titleset`, top-level `move`, and the width-test probes) accept an optional trailing `-console <consolespec>` pair, parsed manually for performance by `punk::console::internal::opt_console_out`/`opt_console_channels` (`_var` variants for procs whose args-tail also carries row/col/data triples). Each carries a documentation-only PUNKARGS definition that includes the shared `::punk::console::argdoc::console_emit_opts` fragment via `punk::args::resolved_def`; keep manual parsing and PUNKARGS synchronized. New emit procs must follow this pattern. Tests live in `src/tests/modules/punk/console/testsuites/console/emitconsole.test`. - punk::console emit-side functions (the `punk::console::ansi::*` emit wrappers, mouse/paste toggles, `vt52`, `set_tabstop_width`, `titleset`, top-level `move`, and the width-test probes) accept an optional trailing `-console <consolespec>` pair, parsed manually for performance by `punk::console::internal::opt_console_out`/`opt_console_channels` (`_var` variants for procs whose args-tail also carries row/col/data triples). Each carries a documentation-only PUNKARGS definition that includes the shared `::punk::console::argdoc::console_emit_opts` fragment via `punk::args::resolved_def`; keep manual parsing and PUNKARGS synchronized. New emit procs must follow this pattern. Tests live in `src/tests/modules/punk/console/testsuites/console/emitconsole.test`.
- punk::console terminal-property facts (is_vt52, tabwidth, cell_size, last_da1_result, grapheme_cluster_support, check::has_bug_*) are per-console: read/write them via `punk::console::console_fact_get`/`console_fact_set`, keyed by canonical {in out} channel pair. The store is tsv-backed (G-007) so all threads read the same values: the process-default console `{stdin stdout}` keeps the legacy namespace variables (`::punk::console::is_vt52`, `tabwidth`, ...) as its authoritative local storage with write traces mirroring into tsv `punk_console_facts` (so existing external readers and direct writers keep working); non-default consoles store facts only in tsv with an owner-qualified key. Do not bypass the helpers for non-default consoles; use `console_fact_clear` (not direct store manipulation) to reset facts in tests. `ansi_wanted`/`colour_disabled` (string-generation gates), `ansi_available` and raw-mode state are deliberately process-global (rationale documented at the fact store in the module). Tests live in `src/tests/modules/punk/console/testsuites/console/consolefacts.test`. - punk::console terminal-property facts (is_vt52, tabwidth, cell_size, last_da1_result, grapheme_cluster_support, check::has_bug_*) are per-console: read/write them via `punk::console::console_fact_get`/`console_fact_set`, keyed by canonical {in out} channel pair. The store is tsv-backed (G-007) so all threads read the same values: the process-default console `{stdin stdout}` keeps the legacy namespace variables (`::punk::console::is_vt52`, `tabwidth`, ...) as its authoritative local storage with write traces mirroring into tsv `punk_console_facts` (so existing external readers and direct writers keep working); non-default consoles store facts only in tsv with an owner-qualified key. Do not bypass the helpers for non-default consoles; use `console_fact_clear` (not direct store manipulation) to reset facts in tests. `ansi_wanted`/`colour_disabled` (string-generation gates), `ansi_available` and raw-mode state are deliberately process-global (rationale documented at the fact store in the module). Tests live in `src/tests/modules/punk/console/testsuites/console/consolefacts.test`.
- punk::console has a console ownership registry (G-007): `console_owner_register`/`console_owner_get`/`console_owner_forget`, tsv `punk_console_owners` keyed by canonical {in out} pair. Ownership is captured when an opunk::console instance is anchored (via `::opunk::console::lifecycle_callback`, wired by `ensure_object_integration`) and by `default_console`; an unregistered console reads as "operate locally". For `{stdin stdout}` first registration wins and only the owner's forget releases the entry. Owner liveness is validated at consult time. The `dec_has_mode`/`ansi_has_mode` caches live in tsv `punk_console_modecache`. - punk::console has a console ownership registry (G-007): `console_owner_register`/`console_owner_get`/`console_owner_forget`, tsv `punk_console_owners` keyed by canonical {in out} pair. Ownership is captured when an opunk::console instance is anchored (via `::opunk::console::lifecycle_callback`, wired by `ensure_object_integration`) and by `default_console`; an unregistered console reads as "operate locally". For `{stdin stdout}` first registration wins and only the owner's forget releases the entry. Owner liveness is validated at consult time. The `dec_has_mode`/`ansi_has_mode` caches live in tsv `punk_console_modecache`.
- punk::console terminal queries are owner-routed (G-007 choke-point brokering): `internal::get_ansi_response_payload` consults `internal::console_route_owner` after spec resolution and forwards the whole call to the console-owning thread via synchronous `thread::send` when the caller is not the owner, so queueing, raw-mode cycling and cooperative reader handling execute in the owner's context and every query proc above the choke point inherits the routing. Routing applies to the default console `{stdin stdout}` only: non-std channel names are thread-local, so an {in out} pair spec names the calling thread's own console and always operates locally (unregistered/self-owned/dead-owner likewise - single-interp behaviour is unchanged). The synchronous send relies on the owner servicing events while the caller blocks (the repl does this while a codethread runs - same property as the repl-installed vt52/colour/mode aliases, which are unaffected because a call arriving in the owner resolves to owner==self). Tests live in `src/tests/modules/punk/console/testsuites/console/ownerrouting.test`.
- Use `punk::args::parse` with `@id` references in `argdoc` namespaces for public API procs. - Use `punk::args::parse` with `@id` references in `argdoc` namespaces for public API procs.
- Private helpers go in `namespace eval private { ... }` blocks. - Private helpers go in `namespace eval private { ... }` blocks.
- Keep `namespace export` lists alphabetized. - Keep `namespace export` lists alphabetized.

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

@ -564,6 +564,27 @@ namespace eval punk::console {
set cinfo [punk::console::console_spec_resolve $inoutchannels] set cinfo [punk::console::console_spec_resolve $inoutchannels]
set inoutchannels [list [dict get $cinfo in] [dict get $cinfo out]] set inoutchannels [list [dict get $cinfo in] [dict get $cinfo out]]
lassign $inoutchannels input output lassign $inoutchannels input output
#G-007 choke-point brokering: a query on a console owned by another thread runs in
#the owning thread - where the cooperative reader protocol (input_chunks_waiting),
#raw-mode arbitration and the authoritative settled can_respond live. Forwarding the
#whole call means the queueing, raw cycling and reader cooperation below all execute
#in the owner's context, and every query proc layered above this choke point
#inherits the routing. Routed before the local can_respond gate deliberately: a
#non-owner context's anchored view of the default console may be unsettled while
#the owner's is settled. The synchronous thread::send is safe because the owner
#services events while this thread blocks (the property the repl-installed vt52
#alias transport already relies on), and re-entry in the owner resolves to
#owner==self so the forward cannot ping-pong.
set route_owner [console_route_owner $inoutchannels]
if {$route_owner ne ""} {
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]]
} on error {errM erropts} {
return -code error "get_ansi_response_payload query routed to console-owning thread $route_owner failed: $errM"
}
}
set governing_obj [dict get $cinfo object] set governing_obj [dict get $cinfo object]
if {$governing_obj ne ""} { if {$governing_obj ne ""} {
set governing_cr [::opunk::Console::get.o_can_respond $governing_obj] set governing_cr [::opunk::Console::get.o_can_respond $governing_obj]
@ -2405,6 +2426,37 @@ namespace eval punk::console {
set owner [punk::console::console_owner_get {stdin stdout}] set owner [punk::console::console_owner_get {stdin stdout}]
return [expr {$owner eq "" || $owner eq [thread::id]}] return [expr {$owner eq "" || $owner eq [thread::id]}]
} }
punk::args::define {
@id -id ::punk::console::internal::console_route_owner
@cmd -name punk::console::internal::console_route_owner -summary\
"Owning thread a console operation must be brokered to, or empty string to operate locally."\
-help\
"Routing consults the ownership registry for the process-default console
{stdin stdout} only: std channel names are process-wide, so any context can
refer to the shared console, but exactly one context owns its reader and
raw-mode arbitration. Non-std channel names are thread-local - an {in out}
pair spec names channels of the calling thread, making that thread the
console's local context - so non-default pairs always operate locally
(a registry entry for such a pair qualifies the shared fact store; it is
not a routing target). A console anchored by code-interp/worker code is
therefore operated on directly by its anchoring context, with no round-trip.
Returns empty string when the caller is the owner or no live owner is
registered."
@values -min 1 -max 1
inoutchannels -type list -help\
"Canonical {in out} channel pair."
}
proc console_route_owner {inoutchannels} {
#see PUNKARGS id ::punk::console::internal::console_route_owner
if {[lindex $inoutchannels 0] ne "stdin" || [lindex $inoutchannels 1] ne "stdout"} {
return ""
}
set owner [punk::console::console_owner_get {stdin stdout}]
if {$owner eq "" || $owner eq [thread::id]} {
return ""
}
return $owner
}
punk::args::define { punk::args::define {
@id -id ::punk::console::internal::console_share_qualifier @id -id ::punk::console::internal::console_share_qualifier
@cmd -name punk::console::internal::console_share_qualifier -summary\ @cmd -name punk::console::internal::console_share_qualifier -summary\

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

@ -1,6 +1,8 @@
0.6.0 0.7.0
#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.0 - G-007 slice 2 (choke-point brokering): a terminal query (internal::get_ansi_response_payload and every query proc layered above it) on the process-default console {stdin stdout} issued by a thread that is not the registered owner is forwarded whole to the owning thread via synchronous thread::send, so queueing, raw-mode cycling, cooperative reader handling (input_chunks_waiting) and can_respond gating all execute in the owner's context; owner-side errors propagate to the caller with routing context
#0.7.0 - new internal::console_route_owner decides the routing: default console pair only (non-std channel names are thread-local, so an {in out} pair spec always names the calling thread's own console and operates locally - a console anchored by code-interp/worker code round-trips nowhere); unregistered, owner==caller, and dead-owner (liveness-validated) cases operate locally, preserving single-interp behaviour exactly
#0.6.0 - G-007 slice 1 (facts + ownership): per-console facts store is tsv-backed (shared array punk_console_facts) so all threads of a punk session read the same values; the default console {stdin stdout} keeps the legacy namespace variables as its authoritative local storage with write traces mirroring every write (including direct variable writes) into tsv; non-default consoles store facts only in tsv, keyed with an owner qualifier because non-std channel names are thread-local #0.6.0 - G-007 slice 1 (facts + ownership): per-console facts store is tsv-backed (shared array punk_console_facts) so all threads of a punk session read the same values; the default console {stdin stdout} keeps the legacy namespace variables as its authoritative local storage with write traces mirroring every write (including direct variable writes) into tsv; non-default consoles store facts only in tsv, keyed with an owner qualifier because non-std channel names are thread-local
#0.6.0 - new console ownership registry: console_owner_register/console_owner_get/console_owner_forget record which thread owns a console (tsv punk_console_owners, keyed by canonical {in out} pair); ownership is captured at anchor time via the new opunk::console lifecycle_callback (wired by ensure_object_integration) and by default_console; consult-time liveness validation clears entries for exited threads; for {stdin stdout} first registration wins and only the owner's forget releases the entry #0.6.0 - new console ownership registry: console_owner_register/console_owner_get/console_owner_forget record which thread owns a console (tsv punk_console_owners, keyed by canonical {in out} pair); ownership is captured at anchor time via the new opunk::console lifecycle_callback (wired by ensure_object_integration) and by default_console; consult-time liveness validation clears entries for exited threads; for {stdin stdout} first registration wins and only the owner's forget releases the entry
#0.6.0 - console_fact_set from a thread that does not own the default console forwards the write to the owning thread via thread::send (vt52-alias transport precedent) with a direct tsv fallback; console_fact_get from a non-owner reads the tsv mirror #0.6.0 - console_fact_set from a thread that does not own the default console forwards the write to the owning thread via thread::send (vt52-alias transport precedent) with a direct tsv fallback; console_fact_get from a non-owner reads the tsv mirror

125
src/tests/modules/punk/console/testsuites/console/ownerrouting.test

@ -0,0 +1,125 @@
package require tcltest
tcltest::configure {*}$::argv
#min-version bounds document that these tests target the dev modules' API and protect against
#stable copies shadowing them if this file is sourced outside runtests.tcl (whose testinterp
#runs 'package prefer latest').
package require punk::console 999999.0a1.0-
#Tests for G-007 choke-point brokering: a terminal query (internal::get_ansi_response_payload
#and every query proc layered above it) on the process-default console {stdin stdout} routes
#to the console-owning thread when the caller is not the owner; an unregistered console, an
#owner==caller console, and any non-default {in out} pair operate locally (non-std channel
#names are thread-local, so a pair spec always names the calling thread's own console).
#The routing decision is internal::console_route_owner; the transport is a synchronous
#thread::send into the owning thread's main interp (the established vt52-alias property:
#the owner services events while the caller blocks).
namespace eval ::testspace {
namespace import ::tcltest::*
variable common {
set result ""
}
test routing_decision {console_route_owner: local for unregistered/self-owned/non-default/dead-owner; owner tid only for the default console owned by another live thread}\
-setup [string cat $common {
set workertid [thread::create {thread::wait}]
}]\
-body {
#unregistered default console - operate locally
lappend result [punk::console::internal::console_route_owner {stdin stdout}]
#caller is the owner - local fast path
punk::console::console_owner_register {stdin stdout}
lappend result [punk::console::internal::console_route_owner {stdin stdout}]
punk::console::console_owner_forget {stdin stdout}
#another live thread owns the default console - route to it
punk::console::console_owner_register {stdin stdout} $workertid
lappend result [expr {[punk::console::internal::console_route_owner {stdin stdout}] eq $workertid}]
#non-default pair never routes, even with a registry entry (the entry qualifies
#the shared fact store; the pair names the calling thread's own channels)
punk::console::console_owner_register {chanx chany} $workertid
lappend result [punk::console::internal::console_route_owner {chanx chany}]
punk::console::console_owner_forget {chanx chany}
#dead owner - consult-time liveness validation clears the entry, operate locally
thread::release $workertid
set deadline [expr {[clock milliseconds] + 2000}]
while {[thread::exists $workertid] && [clock milliseconds] < $deadline} {
after 10
}
lappend result [punk::console::internal::console_route_owner {stdin stdout}]
}\
-cleanup {
catch {punk::console::console_owner_forget {stdin stdout}}
catch {punk::console::console_owner_forget {chanx chany}}
catch {thread::release $workertid}
}\
-result [list\
{}\
{}\
1\
{}\
{}\
]
test routing_query_forwarded_to_owner {a query on the owned default console executes in the owning thread with opts/values intact; query procs above the choke point inherit the routing; owner-side errors propagate with routing context}\
-setup [string cat $common {
set workertid [thread::create {thread::wait}]
#the worker loads punk::console from the same module paths as the testinterp
thread::send $workertid [list set ::tmpaths [tcl::tm::list]]
thread::send $workertid [list set ::auto_path $::auto_path]
thread::send $workertid {
foreach p $::tmpaths {tcl::tm::path add $p}
package prefer latest
package require punk::console 999999.0a1.0-
#replace the real query primitive with a recorder: no real terminal is
#available under runtests, and the transport - not the terminal I/O - is
#what this test exercises
rename ::punk::console::internal::get_ansi_response_payload ::punk::console::internal::get_ansi_response_payload.real
set ::routingtest_mode ok
proc ::punk::console::internal::get_ansi_response_payload {args} {
set ::routingtest_seen [list [thread::id] $args]
if {$::routingtest_mode eq "error"} {
error "recorder-failure"
}
return "5;7"
}
}
punk::console::console_owner_register {stdin stdout} $workertid
}]\
-body {
#direct call on the choke point routes and returns the owner-side result
set payload [punk::console::internal::get_ansi_response_payload -console {stdin stdout} -expected_ms 123 "\x1b\[6n" {(.*)(\x1b\[([0-9]+;[0-9]+)R)$}]
lappend result $payload
lassign [thread::send $workertid {set ::routingtest_seen}] seentid seenargs
#the call executed in the owning thread
lappend result [expr {$seentid eq $workertid}]
#opts and values arrived intact (values are the trailing pair; opts precede them)
lappend result [lrange $seenargs end-1 end]
set seenopts [lrange $seenargs 0 end-2]
lappend result [dict get $seenopts -console]
lappend result [dict get $seenopts -expected_ms]
#a query proc layered above the choke point inherits the routing (get_cursor_pos
#returns the payload unchanged)
lappend result [punk::console::get_cursor_pos]
#an error raised in the owning thread propagates to the caller with routing context
thread::send $workertid {set ::routingtest_mode error}
lappend result [expr {[catch {punk::console::internal::get_ansi_response_payload -console {stdin stdout} "\x1b\[6n" {(.*)(\x1b\[([0-9]+;[0-9]+)R)$}} errM]
&& [string match "*routed to console-owning thread*recorder-failure*" $errM]}]
}\
-cleanup {
catch {punk::console::console_owner_forget {stdin stdout}}
catch {thread::release $workertid}
}\
-result [list\
{5;7}\
1\
[list "\x1b\[6n" {(.*)(\x1b\[([0-9]+;[0-9]+)R)$}]\
{stdin stdout}\
123\
{5;7}\
1\
]
}
tcltest::cleanupTests ;#needed to produce test summary.
Loading…
Cancel
Save