From 6314312aa42763dec05b8276418000637ed1fef0 Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Sun, 5 Jul 2026 16:21:47 +1000 Subject: [PATCH] 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 --- src/modules/punk/AGENTS.md | 1 + src/modules/punk/console-999999.0a1.0.tm | 52 ++++++++ src/modules/punk/console-buildversion.txt | 4 +- .../testsuites/console/ownerrouting.test | 125 ++++++++++++++++++ 4 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 src/tests/modules/punk/console/testsuites/console/ownerrouting.test diff --git a/src/modules/punk/AGENTS.md b/src/modules/punk/AGENTS.md index 8a330485..db4c1ef5 100644 --- a/src/modules/punk/AGENTS.md +++ b/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 ` 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 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. - Private helpers go in `namespace eval private { ... }` blocks. - Keep `namespace export` lists alphabetized. diff --git a/src/modules/punk/console-999999.0a1.0.tm b/src/modules/punk/console-999999.0a1.0.tm index ab489e3f..242a65bc 100644 --- a/src/modules/punk/console-999999.0a1.0.tm +++ b/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 inoutchannels [list [dict get $cinfo in] [dict get $cinfo out]] 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] if {$governing_obj ne ""} { 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}] 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 { @id -id ::punk::console::internal::console_share_qualifier @cmd -name punk::console::internal::console_share_qualifier -summary\ diff --git a/src/modules/punk/console-buildversion.txt b/src/modules/punk/console-buildversion.txt index df40b1a7..ef450191 100644 --- a/src/modules/punk/console-buildversion.txt +++ b/src/modules/punk/console-buildversion.txt @@ -1,6 +1,8 @@ -0.6.0 +0.7.0 #First line must be a semantic version number #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 - 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 diff --git a/src/tests/modules/punk/console/testsuites/console/ownerrouting.test b/src/tests/modules/punk/console/testsuites/console/ownerrouting.test new file mode 100644 index 00000000..4dc5a733 --- /dev/null +++ b/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.