From aca967e1a5d5d075d27f77d371ee2277e3eedb26 Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Sun, 5 Jul 2026 14:03:09 +1000 Subject: [PATCH] punk::console 0.6.0: G-007 slice 1 - tsv-backed console facts + ownership registry Per-console facts are now visible to every thread of a punk session: the default console {stdin stdout} keeps its legacy namespace variables as authoritative local storage, with write traces mirroring every write (including direct variable writes) into tsv punk_console_facts; non-default consoles store facts only in tsv with owner-qualified keys (non-std channel names are thread-local and would otherwise alias). console_fact_set from a non-owner thread forwards to the owning thread (vt52-alias transport) and always writes the tsv mirror itself (covers unreachable or older-version owners). New console_fact_clear for tests/maintenance. New console ownership registry (console_owner_register/get/forget, tsv punk_console_owners keyed by canonical {in out} pair): ownership is captured when an opunk::console instance is anchored - via the new pluggable ::opunk::console::lifecycle_callback (opunk::console 0.4.0, base class unchanged) 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, so a thread anchoring a local view cannot steal ownership. dec_has_mode/ansi_has_mode caches moved to tsv punk_console_modecache (single-key atomic entries shared process-wide). Infrastructure tsv arrays are now punk_-prefixed to avoid collisions with application tsv usage in subshells: console -> punk_console (is_raw), with call-site patches in punk::repl 0.2.1, punk::lib 0.2.1, punk::basictelnet 0.1.1 (rule recorded in src/modules/AGENTS.md; legacy repl/codethread_*/ zzzload_pkg* arrays left for a coordinated follow-up). punk::console is now loadable in secondary threads without ::argv0 (powershell consolemode fallback guarded). Tests: consolefacts gains ownership-registry and cross-thread visibility coverage (worker-thread punk::console load, trace-mirror, forwarded set with deadline-polled sync - see comments re runtests child-interp topology); cleanups use console_fact_clear / tsv instead of poking removed internal stores. Full suite passes (baseline exec-14.3 only). G-007 flipped proposed -> active (user-confirmed); remaining acceptance work is the choke-point brokering slice. Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com --- GOALS.md | 2 +- goals/G-007-console-location-transparency.md | 2 +- src/modules/AGENTS.md | 1 + src/modules/opunk/AGENTS.md | 1 + src/modules/opunk/console-999999.0a1.0.tm | 24 +- src/modules/opunk/console-buildversion.txt | 3 +- src/modules/punk/AGENTS.md | 3 +- src/modules/punk/basictelnet-999999.0a1.0.tm | 2 +- src/modules/punk/basictelnet-buildversion.txt | 3 +- src/modules/punk/console-999999.0a1.0.tm | 474 +++++++++++++++--- src/modules/punk/console-buildversion.txt | 9 +- src/modules/punk/lib-999999.0a1.0.tm | 2 +- src/modules/punk/lib-buildversion.txt | 3 +- src/modules/punk/repl-999999.0a1.0.tm | 16 +- src/modules/punk/repl-buildversion.txt | 3 +- .../testsuites/console/consolefacts.test | 167 +++++- .../testsuites/console/consolespec.test | 15 +- .../testsuites/console/emitconsole.test | 2 +- 18 files changed, 632 insertions(+), 100 deletions(-) diff --git a/GOALS.md b/GOALS.md index 4af4262c..640e21a3 100644 --- a/GOALS.md +++ b/GOALS.md @@ -86,7 +86,7 @@ Detail: goals/G-006-prebuilt-artifact-download.md Goal: pre-built binary artifacts can be downloaded from a separate related binary-artifacts repository or user-configured sources, gated by explicit user consent/configuration by default. Acceptance: a download mechanism fetches binary artifacts (the same set the zig build produces) from a configured source on demand; by default the download is gated behind explicit user consent (a config flag or interactive prompt) and does not occur silently; a user-configured source URL overrides the default binary-artifacts repo; downloaded artifacts satisfy the same build requirements as zig-built artifacts so `tclsh src/make.tcl project` succeeds with downloaded artifacts in place of built ones. -### G-007 [proposed] Location-transparent punk::console across repl and code interps +### G-007 [active] Location-transparent punk::console across repl and code interps Scope: src/modules/punk/console-999999.0a1.0.tm, src/modules/punk/repl-999999.0a1.0.tm, src/modules/punk/repl/codethread-999999.0a1.0.tm Detail: goals/G-007-console-location-transparency.md Goal: punk::console presents one API in every interp/thread of a punk session - code-interp callers see the same console facts and can perform the same queries/operations as the parent, routed to the console-owning context via a punk-side ownership registry, with no `repl eval` required. diff --git a/goals/G-007-console-location-transparency.md b/goals/G-007-console-location-transparency.md index cb94ccdd..c35c9568 100644 --- a/goals/G-007-console-location-transparency.md +++ b/goals/G-007-console-location-transparency.md @@ -1,6 +1,6 @@ # G-007 Location-transparent punk::console across repl and code interps -Status: proposed +Status: active Scope: src/modules/punk/console-999999.0a1.0.tm, src/modules/punk/repl-999999.0a1.0.tm, src/modules/punk/repl/codethread-999999.0a1.0.tm Acceptance: from a running punk session's code interp, without `repl eval`: `console_fact_get` returns the same values the parent sees and a fact set in the parent is immediately visible; a terminal query (e.g. `get_cursor_pos` or `dec_get_mode`) against the default console succeeds and cooperates with the repl reader (no lost or garbled input); a console constructed and owned by code-interp code is operated on locally (no round-trip to the parent); the existing console test suites pass and single-interp (non-repl) usage is unchanged. diff --git a/src/modules/AGENTS.md b/src/modules/AGENTS.md index ee75ab3c..bbd99ad7 100644 --- a/src/modules/AGENTS.md +++ b/src/modules/AGENTS.md @@ -24,6 +24,7 @@ Source of truth for all editable Punk project modules. This is where agents shou - Use semantic versions that `package vcompare` can interpret; strip leading zeros according to tcl package version rules ie in each segment including either side of a or b. - For optional features, probe with `if {[catch {package require foo}]} { ... }` and degrade gracefully. - Vendormodules, bootsupport snapshots, VFS modules, and built output modules do not use the magic version scheme unless their own local docs explicitly say otherwise. +- tsv shared-array names are process-global and subshell application code may use tsv for its own purposes: infrastructure tsv arrays must be `punk_`-prefixed (e.g `punk_console`, `punk_console_facts`). Legacy unprefixed arrays still in use (`repl`, `codethread_*`, `zzzload_pkg*`) predate this rule; rename them only as a coordinated change across all referencing modules. ### Module Subsystems diff --git a/src/modules/opunk/AGENTS.md b/src/modules/opunk/AGENTS.md index 9410a237..964a4f09 100644 --- a/src/modules/opunk/AGENTS.md +++ b/src/modules/opunk/AGENTS.md @@ -18,6 +18,7 @@ Modules under the `opunk::*` namespace explore value-based class implementations - Entity-like objects (e.g. consoles) anchor canonical instances at well-known namespace variables (`::opunk::console::instances::`) with lowercase wrapper procs owning mutation; the value semantics serve as snapshots/copies where cheap. - Cooperative shared state is pluggable rather than hardwired: the probe-byte waiting store is the array named by `::opunk::console::waiting_chunks_arrayvar` (default `::opunk::console::waiting_chunks`); integrating layers redirect it to their established store (punk::console::default_console points it at `punk::console::input_chunks_waiting`) so probe-consumed bytes stay visible to active readers. - Query-based mechanisms enter the class the same way: `::opunk::console::size_query_provider` is a command prefix (invoked with the console object) the base `size` method consults after the -winsize fast path and capability gating; punk::console registers `console_size_provider` via `ensure_object_integration`. Non-channel subclasses override the relevant `-virtual` methods and never touch providers. +- Anchor lifecycle is observable the same way: `::opunk::console::lifecycle_callback` is a command prefix invoked as `{*}$cb created|forgotten {in out}` after `create` anchors / `forget` releases an instance; punk::console registers its handler via `ensure_object_integration` to maintain the G-007 console ownership registry. Empty by default; callback errors propagate to the create/forget caller. - Method-entry validity guards use `[llength $this] < N` (not `!= N`) so field-adding subclasses keep working. - Module filenames use the `-999999.0a1.0.tm` magic suffix with `-buildversion.txt` per parent conventions. - `opunk::console` (with its dependency `voo`) is included in bootsupport via `src/bootsupport/modules/include_modules.config` for eventual boot/repl-startup console objects; after releasing a new version, rebuild with `make.tcl modules` then refresh the snapshot with `make.tcl bootsupport`. diff --git a/src/modules/opunk/console-999999.0a1.0.tm b/src/modules/opunk/console-999999.0a1.0.tm index 33adcbb6..8857407c 100644 --- a/src/modules/opunk/console-999999.0a1.0.tm +++ b/src/modules/opunk/console-999999.0a1.0.tm @@ -75,6 +75,18 @@ tcl::namespace::eval ::opunk::console { if {![info exists size_query_provider]} { set size_query_provider "" } + + #Pluggable anchor-lifecycle callback: a command prefix invoked as + # {*}$lifecycle_callback created {in out} (after the instance is anchored) + # {*}$lifecycle_callback forgotten {in out} (after the anchor is released) + #Empty by default - integrating layers register one to track anchored consoles (punk::console + #maintains its console ownership registry this way). The class/anchor layer itself stays free + #of any punk::console or Thread dependency; callback errors propagate to the create/forget + #caller (a broken integration should be visible, not swallowed). + variable lifecycle_callback + if {![info exists lifecycle_callback]} { + set lifecycle_callback "" + } } voo::class ::opunk::Console -virtual { @@ -256,6 +268,10 @@ tcl::namespace::eval ::opunk::console { error "opunk::console::create name must be alphanumeric/underscore, got '$name'" } set [namespace current]::instances::$name [::opunk::Console::new $in $out] + variable lifecycle_callback + if {$lifecycle_callback ne ""} { + {*}$lifecycle_callback created $name [list $in $out] + } return [namespace current]::instances::$name } proc instancevar {name} { @@ -276,7 +292,13 @@ tcl::namespace::eval ::opunk::console { return $result } proc forget {name} { - unset [instancevar $name] + set v [instancevar $name] + set channels [::opunk::Console::channels [set $v]] + unset $v + variable lifecycle_callback + if {$lifecycle_callback ne ""} { + {*}$lifecycle_callback forgotten $name $channels + } return } diff --git a/src/modules/opunk/console-buildversion.txt b/src/modules/opunk/console-buildversion.txt index 238f9a54..ba0f8fea 100644 --- a/src/modules/opunk/console-buildversion.txt +++ b/src/modules/opunk/console-buildversion.txt @@ -1,6 +1,7 @@ -0.3.0 +0.4.0 #First line must be a semantic version number #all other lines are ignored. +#0.4.0 - new pluggable anchor-lifecycle callback (::opunk::console::lifecycle_callback) invoked as '{*}$cb created|forgotten {in out}' after create anchors / forget releases an instance; empty by default, registered by integrating layers (punk::console maintains its G-007 console ownership registry through it); the class/anchor layer remains free of punk::console and Thread dependencies #0.1.1 - can_respond method docs reference punk::console::settle_can_respond for active-probe settling (no behaviour change) #0.2.0 - probe-byte waiting store changed from dict to array (::opunk::console::waiting_chunks) and made pluggable via waiting_chunks_arrayvar so integrating layers can redirect to an established cooperative store (e.g punk::console::input_chunks_waiting) #0.3.0 - size method gains capability gating (can_respond) and consults the new pluggable ::opunk::console::size_query_provider hook for query-based mechanisms; non-channel subclasses continue to simply override size diff --git a/src/modules/punk/AGENTS.md b/src/modules/punk/AGENTS.md index fd0e3db7..8a330485 100644 --- a/src/modules/punk/AGENTS.md +++ b/src/modules/punk/AGENTS.md @@ -30,7 +30,8 @@ Source of truth for all modules under the `punk::*` namespace. This is the prima - New modules under `punk::*` should be created as `/-999999.0a1.0.tm` following the namespace-to-path convention. - punk::console uses the documented `-console` convention throughout: a `-console` value may be a 2-element {in out} channel list, an anchored `opunk::console` instance name, or an `::opunk::Console` object value (resolved via `punk::console::console_spec_resolve`). Query functions use the hybrid pattern (legacy trailing positional spec also accepted, parsed by `punk::console::internal::hybrid_console_spec`) - new query procs must follow it, with tests (see `src/tests/modules/punk/console/testsuites/console/queryprocs.test`). PUNKARGS definitions include the `-console` option via the shared fragments `::punk::console::argdoc::console_opts` (query/set functions) or `::punk::console::argdoc::console_emit_opts` (emit functions) rather than duplicating the option text; never re-add a `-minsize 2` constraint to `-console` (it rejects instance-name specs). The internal `get_size_using_*` size mechanisms deliberately remain canonical-pair positional (always fed by `get_size`). - 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 process-default console `{stdin stdout}` stores its facts in the legacy namespace variables (`::punk::console::is_vt52`, `tabwidth`, ...) so existing external readers keep working - do not bypass the helpers for non-default consoles. `ansi_wanted`/`colour_disabled` (string-generation gates), `ansi_available` and raw-mode state are deliberately process-global (rationale documented at the `console_facts` 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`. - 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/basictelnet-999999.0a1.0.tm b/src/modules/punk/basictelnet-999999.0a1.0.tm index ecac8175..5feaf496 100644 --- a/src/modules/punk/basictelnet-999999.0a1.0.tm +++ b/src/modules/punk/basictelnet-999999.0a1.0.tm @@ -640,7 +640,7 @@ namespace eval punk::basictelnet { # - review #if we didn't make agreement that server would echo and we're in raw mode - if {![dict get $server_option_state 1] && [tsv::get console is_raw]} { + if {![dict get $server_option_state 1] && [tsv::get punk_console is_raw]} { puts -nonewline stdout $chunk } # -- --- --- --- diff --git a/src/modules/punk/basictelnet-buildversion.txt b/src/modules/punk/basictelnet-buildversion.txt index f47d01c8..d2695fe9 100644 --- a/src/modules/punk/basictelnet-buildversion.txt +++ b/src/modules/punk/basictelnet-buildversion.txt @@ -1,3 +1,4 @@ -0.1.0 +0.1.1 #First line must be a semantic version number #all other lines are ignored. +#0.1.1 - call-site update for punk::console 0.6.0 tsv array rename: console -> punk_console (is_raw); no behaviour change diff --git a/src/modules/punk/console-999999.0a1.0.tm b/src/modules/punk/console-999999.0a1.0.tm index 973bfea6..ab489e3f 100644 --- a/src/modules/punk/console-999999.0a1.0.tm +++ b/src/modules/punk/console-999999.0a1.0.tm @@ -99,8 +99,8 @@ namespace eval punk::console { variable previous_stty_state_stderr "" #variable is_raw 0 - if {![tsv::exists console is_raw]} { - tsv::set console is_raw 0 + if {![tsv::exists punk_console is_raw]} { + tsv::set punk_console is_raw 0 } variable input_chunks_waiting @@ -277,7 +277,7 @@ namespace eval punk::console { variable ansi_available set raw_or_line [string tolower $raw_or_line] if {$raw_or_line eq "query"} { - if {[tsv::get console is_raw]} { + if {[tsv::get punk_console is_raw]} { return "raw" } else { return "line" @@ -684,7 +684,7 @@ namespace eval punk::console { #For mintty-without-winpty (terminal presenting pipes) the twapi raw cycle was already a #no-op via its get_console_handle early-return, so nothing is lost by skipping. set input_is_console_or_tty [expr {[dict exists $previous_input_state -inputmode] || [dict exists $previous_input_state -mode]}] - if {![tsv::get console is_raw]} { + if {![tsv::get punk_console is_raw]} { set was_raw 0 if {$input_is_console_or_tty} { punk::console::enableRaw @@ -2102,6 +2102,11 @@ namespace eval punk::console { if {[info exists ::opunk::console::instances::default]} { #instance exists => opunk::console is loaded - ensure integration wiring applies ensure_object_integration + #the instance may predate the lifecycle-hook wiring (or opunk::console may be an + #older version without it) - make sure the default console has a registered owner + if {[console_owner_get {stdin stdout}] eq ""} { + console_owner_register {stdin stdout} + } return default } if {[catch {package require opunk::console}]} { @@ -2109,29 +2114,40 @@ namespace eval punk::console { } ensure_object_integration opunk::console::create default stdin stdout + if {[console_owner_get {stdin stdout}] eq ""} { + console_owner_register {stdin stdout} + } return default } # -- --- --- --- --- --- --- - #Per-console facts (interim store for the multi-console goals G-001/G-002). + #Per-console facts (G-007 location transparency; originated as interim store for G-001/G-002). #Facts that are properties of a particular terminal (vt52 mode, tabwidth, cell size, quirk - #test results, DA1 response) are keyed by the console's canonical {in out} channel pair. + #test results, DA1 response) are keyed by the console's canonical {in out} channel pair and + #stored in tsv (shared array 'punk_console_facts') so every thread of the process reads the + #same values with no RPC. Infrastructure tsv arrays are punk_-prefixed: tsv names are + #process-global and subshell application code may use tsv for its own purposes. #The process-default console {stdin stdout} keeps the legacy namespace variables #(::punk::console::is_vt52, tabwidth, cell_size, last_da1_result, grapheme_cluster_support, - #::punk::console::check::has_bug_*) as its storage, so external readers of those variables - #(punk::repl, textblock, overtype, punk::ansi, punk::ns) continue to see current values. + #::punk::console::check::has_bug_*) as its authoritative local storage, so external readers + #AND writers of those variables (punk::repl, textblock, overtype, punk::ansi, punk::ns, test + #suites) continue to work. Write traces on those variables (installed at the end of this + #module) mirror every write - including direct variable writes that bypass console_fact_set - + #into tsv, where non-owner threads read them. Which context may treat the legacy variables as + #authoritative is decided by the console ownership registry below (unregistered = every + #context is local, preserving single-interp behaviour exactly). + #Non-default consoles store facts only in tsv, with the key qualified by the owner context + #(registry owner, else the calling thread) because non-std channel *names* are thread-local - + #two threads can each have a channel named e.g sock10 referring to different consoles, and an + #unqualified key would alias them. #Deliberately NOT stored here (process-global): # - ansi_wanted/colour_disabled - they gate string *generation* (code_a+ etc) where the # eventual output console is unknown # - ansi_available - determined via the real process console (twapi); object consoles carry # their own settled can_respond capability instead - # - tsv console is_raw and the enableRaw/disableRaw family - raw mode is a property of the + # - tsv punk_console is_raw and the enableRaw/disableRaw family - raw mode is a property of the # real attached console; get_ansi_response_payload already guards against cycling it for # non-console channels - variable console_facts - if {![info exists console_facts]} { - set console_facts [dict create] - } variable console_fact_info set console_fact_info [dict create {*}{ is_vt52 {legacyvar ::punk::console::is_vt52 default 0} @@ -2142,13 +2158,105 @@ namespace eval punk::console { has_bug_legacysymbolwidth {legacyvar ::punk::console::check::has_bug_legacysymbolwidth default -1} has_bug_zwsp {legacyvar ::punk::console::check::has_bug_zwsp default -1} }] + + # -- --- --- --- --- --- --- + #Console ownership registry (G-007). + #Exactly one context can own an input channel's reader and the process console's raw mode - + #operations on a console you do not own must be brokered to the owner. This registry records + #which thread that is, per console, keyed by canonical {in out} channel pair in tsv (shared + #array 'punk_console_owners') so any thread can consult it. + #Ownership is captured at registration time: the context anchoring an opunk::console instance + #is recorded via the ::opunk::console lifecycle callback (wired by ensure_object_integration), + #and default_console registers the default console {stdin stdout} - in a punk session that + #happens in the repl thread during repl::init. Re-registration (last anchor wins) is + #deliberate: ownership is process topology, not a terminal fact, and consoles can be handed + #between contexts. Consult-time liveness validation clears entries whose owning thread has + #exited, recovering lifecycle cohesion when forget was never called. + lappend PUNKARGS [list { + @id -id ::punk::console::console_owner_register + @cmd -name punk::console::console_owner_register -summary\ + "Record the owning thread for a console in the process-wide ownership registry."\ + -help\ + "The owner is the context whose reader/raw-mode arbitration governs the console - + operations from other contexts consult the registry to route to it. Normally called + automatically when an opunk::console instance is anchored (and by default_console); + direct calls are for consoles managed without the object layer. Re-registration + overwrites (last anchor wins). Returns the recorded owner thread id." + @values -min 1 -max 2 + consolespec -type list -help\ + "Console specification: {in out} channel pair, anchored opunk::console instance + name, or ::opunk::Console object value." + ownerthread -type string -optional 1 -help\ + "Thread id of the owning thread (as returned by thread::id in that thread). + Defaults to the calling thread." + }] + proc console_owner_register {consolespec {ownerthread ""}} { + #see PUNKARGS id ::punk::console::console_owner_register + set pair [internal::spec_to_channelpair $consolespec] + if {$ownerthread eq ""} { + set ownerthread [thread::id] + } + tsv::set punk_console_owners $pair $ownerthread + return $ownerthread + } + lappend PUNKARGS [list { + @id -id ::punk::console::console_owner_get + @cmd -name punk::console::console_owner_get -summary\ + "Return the registered owning thread id for a console, or empty string."\ + -help\ + "Returns empty string when the console is unregistered - callers treat that as + 'operate locally' so single-interp (non-repl) usage is unaffected by the registry. + Owner liveness is validated at consult time: a registry entry whose thread no longer + exists is cleared and reported as unregistered." + @values -min 1 -max 1 + consolespec -type list -help\ + "Console specification: {in out} channel pair, anchored opunk::console instance + name, or ::opunk::Console object value." + }] + proc console_owner_get {consolespec} { + #see PUNKARGS id ::punk::console::console_owner_get + set pair [internal::spec_to_channelpair $consolespec] + if {![tsv::get punk_console_owners $pair owner]} { + return "" + } + if {[catch {thread::exists $owner} alive] || !$alive} { + #stale entry - owning thread has exited (or recorded handle is invalid) + catch {tsv::unset punk_console_owners $pair} + return "" + } + return $owner + } + lappend PUNKARGS [list { + @id -id ::punk::console::console_owner_forget + @cmd -name punk::console::console_owner_forget -summary\ + "Remove a console's entry from the ownership registry."\ + -help\ + "Normally called automatically when the anchored opunk::console instance is + forgotten. A forgotten console reads as unregistered (operate locally)." + @values -min 1 -max 1 + consolespec -type list -help\ + "Console specification: {in out} channel pair, anchored opunk::console instance + name, or ::opunk::Console object value." + }] + proc console_owner_forget {consolespec} { + #see PUNKARGS id ::punk::console::console_owner_forget + set pair [internal::spec_to_channelpair $consolespec] + if {[tsv::exists punk_console_owners $pair]} { + tsv::unset punk_console_owners $pair + } + return + } + # -- --- --- --- --- --- --- + lappend PUNKARGS [list { @id -id ::punk::console::console_fact_get @cmd -name punk::console::console_fact_get -summary\ "Read a per-console fact for the specified console."\ -help\ - "Facts for the process-default console {stdin stdout} are stored in the legacy - namespace variables; other consoles use a store keyed by canonical channel pair. + "Facts for the process-default console {stdin stdout} are read from the legacy + namespace variables in the console's owning context (or any context when + unregistered), and from their tsv mirror elsewhere; other consoles use the tsv + store keyed by owner-qualified canonical channel pair. Returns the fact's default when nothing has been recorded for the console." @values -min 2 -max 2 consolespec -type list -help\ @@ -2159,16 +2267,21 @@ namespace eval punk::console { proc console_fact_get {consolespec key} { #see PUNKARGS id ::punk::console::console_fact_get variable console_fact_info - if {[llength $consolespec] != 2} { - set cinfo [console_spec_resolve $consolespec] - set consolespec [list [dict get $cinfo in] [dict get $cinfo out]] - } + set consolespec [internal::spec_to_channelpair $consolespec] if {[lindex $consolespec 0] eq "stdin" && [lindex $consolespec 1] eq "stdout"} { + if {[internal::is_default_console_context]} { + return [set [dict get $console_fact_info $key legacyvar]] + } + #another context owns the default console - its legacy-variable writes (traced) are + #mirrored in tsv + if {[tsv::get punk_console_facts [list {} stdin stdout $key] mirrored]} { + return $mirrored + } + #nothing mirrored yet - this context's own legacy variable holds the module default return [set [dict get $console_fact_info $key legacyvar]] } - variable console_facts - if {[dict exists $console_facts $consolespec $key]} { - return [dict get $console_facts $consolespec $key] + if {[tsv::get punk_console_facts [internal::console_fact_key $consolespec $key] val]} { + return $val } return [dict get $console_fact_info $key default] } @@ -2178,8 +2291,11 @@ namespace eval punk::console { "Record a per-console fact for the specified console."\ -help\ "Facts for the process-default console {stdin stdout} are stored in the legacy - namespace variables (visible to their external readers); other consoles use a - store keyed by canonical channel pair. Returns the value." + namespace variables (visible to their external readers) with a traced tsv mirror; + a write from a context that does not own the default console is forwarded to the + owning thread so the owner's legacy variables stay authoritative. Other consoles + use the tsv store keyed by owner-qualified canonical channel pair. Returns the + value." @values -min 3 -max 3 consolespec -type list -help\ "Console specification: {in out} channel pair, anchored opunk::console instance @@ -2190,18 +2306,216 @@ namespace eval punk::console { proc console_fact_set {consolespec key value} { #see PUNKARGS id ::punk::console::console_fact_set variable console_fact_info - if {[llength $consolespec] != 2} { - set cinfo [console_spec_resolve $consolespec] - set consolespec [list [dict get $cinfo in] [dict get $cinfo out]] - } + set consolespec [internal::spec_to_channelpair $consolespec] if {[lindex $consolespec 0] eq "stdin" && [lindex $consolespec 1] eq "stdout"} { - set [dict get $console_fact_info $key legacyvar] $value + set owner [console_owner_get {stdin stdout}] + if {$owner eq "" || $owner eq [thread::id]} { + #this context is authoritative - the write trace mirrors the value to tsv + set [dict get $console_fact_info $key legacyvar] $value + } else { + #keep this thread's own legacy variable current for its local readers + #(the write trace does not mirror from a non-owner context) + set [dict get $console_fact_info $key legacyvar] $value + #apply the write in the owning thread so the owner's legacy variables (its + #authoritative local store) stay in sync - safe while the owner services + #events, as the repl thread does while a codethread executes (the established + #vt52-alias transport). thread::send executes in the target thread's MAIN + #interp (where a punk session's console-owning punk::console lives); routing + #for nested/child interps is the interp-alias layer of G-007, not this path. + catch {thread::send $owner [list ::punk::console::console_fact_set [list stdin stdout] $key $value]} + #ensure the tsv mirror holds the value regardless of the send outcome: the + #owner's write trace mirrors it with a current punk::console, but the owner + #may be unreachable, have no punk::console in its main interp, or run an + #older punk::console that accepts the write without mirroring (idempotent + #when the trace already did it) + tsv::set punk_console_facts [list {} stdin stdout $key] $value + } } else { - variable console_facts - dict set console_facts $consolespec $key $value + tsv::set punk_console_facts [internal::console_fact_key $consolespec $key] $value } return $value } + lappend PUNKARGS [list { + @id -id ::punk::console::console_fact_clear + @cmd -name punk::console::console_fact_clear -summary\ + "Clear all recorded facts for the specified console."\ + -help\ + "For the process-default console {stdin stdout} every fact is re-recorded at its + module default (via console_fact_set, so ownership forwarding and the tsv mirror + apply); for other consoles the recorded tsv entries are removed so subsequent + reads fall back to the fact defaults. Primarily for tests and maintenance." + @values -min 1 -max 1 + consolespec -type list -help\ + "Console specification: {in out} channel pair, anchored opunk::console instance + name, or ::opunk::Console object value." + }] + proc console_fact_clear {consolespec} { + #see PUNKARGS id ::punk::console::console_fact_clear + variable console_fact_info + set consolespec [internal::spec_to_channelpair $consolespec] + if {[lindex $consolespec 0] eq "stdin" && [lindex $consolespec 1] eq "stdout"} { + dict for {key finfo} $console_fact_info { + console_fact_set $consolespec $key [dict get $finfo default] + } + } else { + dict for {key finfo} $console_fact_info { + set tsvkey [internal::console_fact_key $consolespec $key] + if {[tsv::exists punk_console_facts $tsvkey]} { + tsv::unset punk_console_facts $tsvkey + } + } + } + return + } + + namespace eval internal { + #Helpers for the per-console fact store and ownership registry (G-007). + punk::args::define { + @id -id ::punk::console::internal::spec_to_channelpair + @cmd -name punk::console::internal::spec_to_channelpair -summary\ + "Resolve any console spec form to its canonical {in out} channel pair."\ + -help\ + "2-element specs are already canonical and returned as-is (no object-layer + cost); instance-name and object-value specs resolve via console_spec_resolve." + @values -min 1 -max 1 + spec -type list -help\ + "Console specification: {in out} channel pair, anchored opunk::console instance + name, or ::opunk::Console object value." + } + proc spec_to_channelpair {spec} { + #see PUNKARGS id ::punk::console::internal::spec_to_channelpair + if {[llength $spec] != 2} { + set cinfo [punk::console::console_spec_resolve $spec] + return [list [dict get $cinfo in] [dict get $cinfo out]] + } + return $spec + } + punk::args::define { + @id -id ::punk::console::internal::is_default_console_context + @cmd -name punk::console::internal::is_default_console_context -summary\ + "True when the calling thread may treat the default-console legacy variables as authoritative."\ + -help\ + "True when the calling thread is the registered owner of the default console + {stdin stdout}, or when no owner is registered (single-interp/non-repl usage + behaves exactly as before the ownership registry existed)." + @values -min 0 -max 0 + } + proc is_default_console_context {} { + #see PUNKARGS id ::punk::console::internal::is_default_console_context + 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_share_qualifier + @cmd -name punk::console::internal::console_share_qualifier -summary\ + "Owner qualifier used in shared (tsv) store keys for a canonical channel pair."\ + -help\ + "Empty string for the process-default console {stdin stdout} (std channels are + process-wide names). For other consoles: the registered owner thread, else the + calling thread - non-std channel names are thread-local, so unqualified keys + would alias distinct consoles that happen to share a channel name across + threads." + @values -min 1 -max 1 + inoutchannels -type list -help\ + "Canonical {in out} channel pair." + } + proc console_share_qualifier {inoutchannels} { + #see PUNKARGS id ::punk::console::internal::console_share_qualifier + if {[lindex $inoutchannels 0] eq "stdin" && [lindex $inoutchannels 1] eq "stdout"} { + return "" + } + set owner [punk::console::console_owner_get $inoutchannels] + if {$owner ne ""} { + return $owner + } + return [thread::id] + } + punk::args::define { + @id -id ::punk::console::internal::console_fact_key + @cmd -name punk::console::internal::console_fact_key -summary\ + "tsv key for a fact of a non-default console: {qualifier in out factkey}." + @values -min 2 -max 2 + inoutchannels -type list -help\ + "Canonical {in out} channel pair." + key -type string -help\ + "Fact key (as accepted by console_fact_get/console_fact_set)." + } + proc console_fact_key {inoutchannels key} { + #see PUNKARGS id ::punk::console::internal::console_fact_key + return [list [console_share_qualifier $inoutchannels] [lindex $inoutchannels 0] [lindex $inoutchannels 1] $key] + } + punk::args::define { + @id -id ::punk::console::internal::default_fact_write_trace + @cmd -name punk::console::internal::default_fact_write_trace -summary\ + "Variable write-trace callback mirroring default-console legacy fact variables to tsv."\ + -help\ + "Installed at module load on each legacy fact variable so every write - including + direct variable writes that bypass console_fact_set - is visible to other + threads. Only mirrors when the calling context is authoritative for the default + console (a non-owner thread's hollow copies of the variables must not clobber + the owner's values)." + @leaders + key -type string -help\ + "Fact key the traced variable stores." + legacyvar -type string -help\ + "Fully qualified name of the traced legacy variable." + @values -min 0 -max 3 + traceargs -type any -optional 1 -multiple 1 -help\ + "name1 name2 op appended by the trace mechanism (unused)." + } + proc default_fact_write_trace {key legacyvar args} { + #see PUNKARGS id ::punk::console::internal::default_fact_write_trace + if {[is_default_console_context]} { + tsv::set punk_console_facts [list {} stdin stdout $key] [set $legacyvar] + } + } + punk::args::define { + @id -id ::punk::console::internal::object_console_lifecycle + @cmd -name punk::console::internal::object_console_lifecycle -summary\ + "opunk::console lifecycle callback maintaining the console ownership registry."\ + -help\ + "Registered as ::opunk::console::lifecycle_callback by ensure_object_integration. + The context anchoring a console instance is recorded as that console's owner; + forgetting the instance clears the entry. For the process-default console + {stdin stdout} the first registration wins: anchored instances are per-thread, + so a second thread anchoring the std channels is creating a local view of the + already-owned console, not taking over its reader. Non-std channel names are + thread-local, so for other pairs each anchor is a genuinely distinct console + and the latest registration wins." + @values -min 3 -max 3 + event -type string -choices {created forgotten} -help\ + "Lifecycle event." + name -type string -help\ + "Anchored instance name (unused - ownership is keyed by channel pair)." + channels -type list -help\ + "Canonical {in out} channel pair of the instance." + } + proc object_console_lifecycle {event name channels} { + #see PUNKARGS id ::punk::console::internal::object_console_lifecycle + switch -exact -- $event { + created { + if {[lindex $channels 0] eq "stdin" && [lindex $channels 1] eq "stdout"} { + if {[punk::console::console_owner_get $channels] ne ""} { + return + } + } + punk::console::console_owner_register $channels + } + forgotten { + if {[lindex $channels 0] eq "stdin" && [lindex $channels 1] eq "stdout"} { + #only the owner's forget releases the default console - a non-owner + #thread forgetting its local anchor view must not clear the entry + set owner [punk::console::console_owner_get $channels] + if {$owner ne "" && $owner ne [thread::id]} { + return + } + } + punk::console::console_owner_forget $channels + } + } + return + } + } # -- --- --- --- --- --- --- #default timeout for the active can_respond settling probe (see settle_can_respond) @@ -2501,6 +2815,12 @@ namespace eval punk::console { if {$::opunk::console::size_query_provider eq ""} { set ::opunk::console::size_query_provider [list ::punk::console::console_size_provider] } + #ownership registry maintenance rides the anchor lifecycle (older opunk::console + #versions have no lifecycle_callback - default_console's explicit registration and + #consult-time liveness validation still cover the default console there) + if {[info exists ::opunk::console::lifecycle_callback] && $::opunk::console::lifecycle_callback eq ""} { + set ::opunk::console::lifecycle_callback [list ::punk::console::internal::object_console_lifecycle] + } set object_integration_done 1 } @@ -2903,8 +3223,9 @@ namespace eval punk::console { puts -nonewline $term_out "\x1b\[?${modes_string}l" } - variable dec_has_mode_cache - set dec_has_mode_cache [dict create] + #dec_has_mode/ansi_has_mode results are cached in tsv (shared array 'punk_console_modecache', + #one entry per {dectype qualifier in out passthrough mode} key) so every thread of the + #process shares one cache per console and cache writes are single-key atomic (G-007). namespace eval argdoc { lappend PUNKARGS [list { @id -id ::punk::console::dec_has_mode @@ -2960,19 +3281,19 @@ namespace eval punk::console { error "punk::console::dec_has_mode unrecognised mode '$num_or_name'. Known mode names: [dict keys $decmode_names]" } } - variable dec_has_mode_cache set cinfo [console_spec_resolve $console] set console [list [dict get $cinfo in] [dict get $cinfo out]] - #cache on the canonical channel pair and passthrough - all -console spec forms - #addressing the same console share one cache entry - set cachekey "$console $passthrough" + #cache in tsv on the canonical channel pair (owner-qualified for non-default consoles) + #and passthrough - all -console spec forms addressing the same console share one cache + #entry, visible to all threads of the process + set cachekey [list dec [internal::console_share_qualifier $console] [lindex $console 0] [lindex $console 1] $passthrough $m] if {$do_refresh} { - if {[dict exists $dec_has_mode_cache $cachekey $m]} { - dict unset dec_has_mode_cache $cachekey $m + if {[tsv::exists punk_console_modecache $cachekey]} { + tsv::unset punk_console_modecache $cachekey } } - if {![dict exists $dec_has_mode_cache $cachekey $m]} { + if {![tsv::get punk_console_modecache $cachekey has_mode]} { set capturingregex [string map [list %MODE% $m] {(.*)(\x1b\[\?%MODE%;([0-9]+)\$y)$}] ;#must capture prefix,entire-response,response-payload set request "\x1b\[?$m\$p" set payload [punk::console::internal::get_ansi_response_payload -console $console -passthrough $passthrough $request $capturingregex] @@ -2980,7 +3301,7 @@ namespace eval punk::console { #we can use the payload result as the response as non-zero responses evaluate to true set has_mode $payload if {$has_mode ne ""} { - dict set dec_has_mode_cache $cachekey $m $has_mode + tsv::set punk_console_modecache $cachekey $has_mode set source "query" } else { #don't cache an empty/failed response - review @@ -2988,7 +3309,6 @@ namespace eval punk::console { set source "failedquery" } } else { - set has_mode [dict get $dec_has_mode_cache $cachekey $m] set source "cache" } if {$return eq "dict"} { @@ -3163,8 +3483,6 @@ namespace eval punk::console { return $out } - variable ansi_has_mode_cache - set ansi_has_mode_cache [dict create] namespace eval argdoc { lappend PUNKARGS [list { @id -id ::punk::console::ansi_has_mode @@ -3214,26 +3532,26 @@ namespace eval punk::console { error "punk::console::ansi_has_mode unrecognised mode '$num_or_name'. Known mode names: [dict keys $ansimode_names]" } } - variable ansi_has_mode_cache set cinfo [console_spec_resolve $console] set console [list [dict get $cinfo in] [dict get $cinfo out]] - #cache on the canonical channel pair and passthrough - all -console spec forms - #addressing the same console share one cache entry - set cachekey "$console $passthrough" + #cache in tsv on the canonical channel pair (owner-qualified for non-default consoles) + #and passthrough - all -console spec forms addressing the same console share one cache + #entry, visible to all threads of the process + set cachekey [list ansi [internal::console_share_qualifier $console] [lindex $console 0] [lindex $console 1] $passthrough $m] if {$do_refresh} { - if {[dict exists $ansi_has_mode_cache $cachekey $m]} { - dict unset ansi_has_mode_cache $cachekey $m + if {[tsv::exists punk_console_modecache $cachekey]} { + tsv::unset punk_console_modecache $cachekey } } - if {![dict exists $ansi_has_mode_cache $cachekey $m]} { + if {![tsv::get punk_console_modecache $cachekey has_mode]} { set capturingregex [string map [list %MODE% $m] {(.*)(\x1b\[%MODE%;([0-9]+)\$y)$}] ;#must capture prefix,entire-response,response-payload set request "\x1b\[$m\$p" set payload [punk::console::internal::get_ansi_response_payload -console $console -passthrough $passthrough $request $capturingregex] #set has_mode [expr {$payload != 0}] set has_mode $payload if {$has_mode ne ""} { - dict set ansi_has_mode_cache $cachekey $m $has_mode + tsv::set punk_console_modecache $cachekey $has_mode set source "query" } else { #don't cache an empty/failed response - review @@ -3241,7 +3559,6 @@ namespace eval punk::console { set source "failedquery" } } else { - set has_mode [dict get $ansi_has_mode_cache $cachekey $m] set source "cache" } if {$return eq "dict"} { @@ -4141,7 +4458,7 @@ namespace eval punk::console { #todo - compare speed with get_cursor_pos - work out why the big difference proc test_cursor_pos {} { - if {![tsv::get console is_raw]} { + if {![tsv::get punk_console is_raw]} { set was_raw 0 enableRaw } else { @@ -5298,9 +5615,9 @@ namespace eval punk::console::system { } } if {[catch { - tsv::set console is_raw 1 + tsv::set punk_console is_raw 1 } errMsg]} { - puts stderr "enableRaw_stty failed to run tsv::set console is_raw 1: $errMsg" + puts stderr "enableRaw_stty failed to run tsv::set punk_console is_raw 1: $errMsg" } catch { chan configure stdin -inputmode raw @@ -5326,9 +5643,9 @@ namespace eval punk::console::system { } } if {[catch { - tsv::set console is_raw 0 + tsv::set punk_console is_raw 0 } errMsg]} { - puts stderr "disableRaw_stty failed to run tsv::set console is_raw 0: $errMsg" + puts stderr "disableRaw_stty failed to run tsv::set punk_console is_raw 0: $errMsg" } catch { chan configure stdin -inputmode normal @@ -5357,7 +5674,7 @@ namespace eval punk::console::system { } } catch { - tsv::set console is_raw 1 + tsv::set punk_console is_raw 1 } catch { @@ -5387,7 +5704,7 @@ namespace eval punk::console::system { } catch { - tsv::set console is_raw 0 + tsv::set punk_console is_raw 0 } #mintty on windows seems to have -inputmode available when running with winpty, #and is missing the -inputmode key when running without winpty. @@ -5420,7 +5737,7 @@ namespace eval punk::console::system { #set newmode [dict merge $oldmode [dict create -lineinput 0 -echoinput 0]] set newmode [twapi::get_console_input_mode] - tsv::set console is_raw 1 + tsv::set punk_console is_raw 1 #don't disable handler - it will detect is_raw ### twapi::set_console_control_handler {} return [list stdin [list from $oldmode to $newmode]] @@ -5431,7 +5748,7 @@ namespace eval punk::console::system { set ch_state [chan conf $channel] if {[dict exists $ch_state -inputmode]} { chan conf $channel -inputmode normal - tsv::set console is_raw 0 + tsv::set punk_console is_raw 0 return [list $channel [list from [dict get $ch_state -inputmode] to normal]] } else { if {[catch {twapi::get_console_handle stdin} console_handle]} { @@ -5443,7 +5760,7 @@ namespace eval punk::console::system { # Turn on the echo and line-editing bits twapi::modify_console_input_mode $console_handle -lineinput 1 -echoinput 1 set newmode [twapi::get_console_input_mode] - tsv::set console is_raw 0 + tsv::set punk_console is_raw 0 return [list stdin [list from $oldmode to $newmode]] } } @@ -5483,7 +5800,7 @@ namespace eval punk::console::system { } } if {$wrote} { - tsv::set console is_raw 1 + tsv::set punk_console is_raw 1 #after 100 close $pipe } else { @@ -5501,7 +5818,7 @@ namespace eval punk::console::system { } exec {*}$sttycmd raw -echo <@$channel - tsv::set console is_raw 1 + tsv::set punk_console is_raw 1 #review - inconsistent return dict return [dict create stdin [list from [set previous_stty_state_$channel] to "" note "fixme - to state not shown"]] } else { @@ -5515,7 +5832,7 @@ namespace eval punk::console::system { set ch_state [chan conf $channel] if {[dict exists $ch_state -inputmode]} { chan conf $channel -inputmode normal - tsv::set console is_raw 0 + tsv::set punk_console is_raw 0 return [list $channel [list from [dict get $ch_state -inputmode] to normal]] } else { #tcl <= 8.6x doesn't support -inputmode @@ -5529,7 +5846,7 @@ namespace eval punk::console::system { return restored } exec {*}$sttycmd -raw echo <@$channel - tsv::set console is_raw 0 + tsv::set punk_console is_raw 0 #do we really want to exec stty yet again to show final 'to' state? #probably not. We should work out how to read the stty result flags and set a result.. or just limit from,to to showing echo and lineedit states. return [list stdin [list from "[set previous_stty_state_$channel]" to "" note "fixme - to state not shown"]] @@ -5719,7 +6036,9 @@ namespace eval punk::console { variable ps_pipename if {![info exists ps_consolemode_contents]} { #start persistent powershell consolemode_server.ps1 named pipe server - if {$::argv0 ne ""} { + #::argv0 is absent in secondary threads (thread::create workers, codethreads) - + #the module must remain loadable there + if {[info exists ::argv0] && $::argv0 ne ""} { set pstooldir [file dirname [file dirname [file normalize $::argv0]]]/scriptlib/utils/pwsh } else { set pstooldir [pwd] @@ -5853,6 +6172,25 @@ namespace eval punk::console { } +namespace eval punk::console { + #G-007: mirror every write of the default-console legacy fact variables into tsv so + #non-owner threads read current values (see the console_facts store comments). Installed + #here, at the end of the module, so all legacy fact variables and their namespaces exist. + #Re-source safe: an identical previously-installed trace is removed before adding. + dict for {factkey factinfo} $console_fact_info { + set _legacyvar [dict get $factinfo legacyvar] + set _tracecmd [list ::punk::console::internal::default_fact_write_trace $factkey $_legacyvar] + foreach _tinfo [trace info variable $_legacyvar] { + lassign $_tinfo _top _tcmd + if {$_top eq "write" && $_tcmd eq $_tracecmd} { + trace remove variable $_legacyvar write $_tracecmd + } + } + trace add variable $_legacyvar write $_tracecmd + } + unset -nocomplain factkey factinfo _legacyvar _tracecmd _tinfo _top _tcmd +} + namespace eval ::punk::args::register { #use fully qualified so 8.6 doesn't find existing var in global namespace lappend ::punk::args::register::NAMESPACES ::punk::console ::punk::console::argdoc ::punk::console::internal ::punk::console::local ::punk::console::ansi ::punk::console::check diff --git a/src/modules/punk/console-buildversion.txt b/src/modules/punk/console-buildversion.txt index d8406a1c..df40b1a7 100644 --- a/src/modules/punk/console-buildversion.txt +++ b/src/modules/punk/console-buildversion.txt @@ -1,6 +1,13 @@ -0.5.0 +0.6.0 #First line must be a semantic version number #all other lines are ignored. +#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 +#0.6.0 - new console_fact_clear proc clears recorded facts for a console (default console facts re-record their module defaults) +#0.6.0 - dec_has_mode/ansi_has_mode caches moved from per-interp dicts to tsv (shared array punk_console_modecache, one entry per {dectype qualifier in out passthrough mode}) - all threads share one cache per console +#0.6.0 - infrastructure tsv arrays renamed with punk_ prefix to avoid collisions with application tsv usage in subshells: console -> punk_console (is_raw; coordinated call-site updates in punk::repl, punk::lib, punk::basictelnet), and the new stores are punk_console_facts/punk_console_owners/punk_console_modecache; mixed loading of pre-0.6.0 and 0.6.0 punk::console in one process would see divergent raw-mode state +#0.6.0 - module is now loadable in secondary threads without ::argv0 (powershell consolemode fallback guarded with info exists) #0.5.0 - migration complete: the remaining trailing-positional query functions (get_cursor_pos, get_cursor_pos_list, get_checksum_rect, get_device_status, get_device_attributes, get_device_attributes_tertiary, get_terminal_id, get_tabstops, get_tabstop_apparent_width, get_dimensions, get_xterm_size, get_xterm_pixels, dec_get_mode_line_wrap, ansi_get_mode_LNM) converted to the get_size hybrid pattern via new internal::hybrid_console_spec - each accepts the legacy positional console spec or -console (any spec form), with PUNKARGS documentation #0.5.0 - get_size refactored onto internal::hybrid_console_spec (behaviour unchanged) #0.5.0 - documentation-only PUNKARGS added for input_at_eof, is_input_console_or_tty, size_via_query_mechanisms, console_size_provider and the get_size_using_* mechanism helpers (signatures unchanged - internal size mechanisms remain canonical-pair only) diff --git a/src/modules/punk/lib-999999.0a1.0.tm b/src/modules/punk/lib-999999.0a1.0.tm index e674a11d..5b5733f3 100644 --- a/src/modules/punk/lib-999999.0a1.0.tm +++ b/src/modules/punk/lib-999999.0a1.0.tm @@ -6706,7 +6706,7 @@ namespace eval punk::lib { set stdin_state [chan configure stdin] if {[catch { package require punk::console - set console_raw [tsv::get console is_raw] + set console_raw [tsv::get punk_console is_raw] } err_console]} { #assume normal line mode set console_raw 0 diff --git a/src/modules/punk/lib-buildversion.txt b/src/modules/punk/lib-buildversion.txt index c0497672..8964d069 100644 --- a/src/modules/punk/lib-buildversion.txt +++ b/src/modules/punk/lib-buildversion.txt @@ -1,5 +1,6 @@ -0.2.0 +0.2.1 #First line must be a semantic version number #all other lines are ignored. +#0.2.1 - call-site update for punk::console 0.6.0 tsv array rename: console -> punk_console (is_raw); no behaviour change #0.2.0 - new: snapshot_package_paths proc returns a script string reproducing tm list, auto_path, and package prefer for use in thread::create init scripts #0.2.0 - extended: interp_sync_package_paths now propagates package prefer and supports optional -libunknown 1 flag for epoch copy + libunknown init diff --git a/src/modules/punk/repl-999999.0a1.0.tm b/src/modules/punk/repl-999999.0a1.0.tm index 789e65ae..5233262f 100644 --- a/src/modules/punk/repl-999999.0a1.0.tm +++ b/src/modules/punk/repl-999999.0a1.0.tm @@ -162,7 +162,7 @@ namespace eval repl { #variable last_unknown "" tsv::set repl last_unknown "" - tsv::set console is_raw 0 + tsv::set punk_console is_raw 0 variable output "" #important not to initialize - as it can be preset by cooperating package before app-punk has been package required #(this is an example of a deaddrop) @@ -276,7 +276,7 @@ proc ::punk::repl::init_signal_handlers {} { flush stderr incr signal_control_c #rputs stderr "* console_control: $args" - if {[tsv::get console is_raw]} { + if {[tsv::get punk_console is_raw]} { if {[lindex $::errorCode 0] eq "CHILDKILLED"} { #rputs stderr "\n|repl> ctrl-c errorCode: $::errorCode" #avoid spurious triggers after interrupting a command.. @@ -1643,10 +1643,10 @@ proc repl::repl_handler {inputchan readmore prompt_config} { if {[dict get $original_input_conf -inputmode] eq "raw"} { #user or script has apparently put stdin into raw mode - update punk::console::is_raw to match set rawmode 1 - tsv::set console is_raw 1 + tsv::set punk_console is_raw 1 } else { #set ::punk::console::is_raw 0 - tsv::set console is_raw 0 + tsv::set punk_console is_raw 0 } #what about enable/disable virtualTerminal ? #using stdin -inputmode to switch modes won't set virtualterminal input state appropriately @@ -1654,7 +1654,7 @@ proc repl::repl_handler {inputchan readmore prompt_config} { #if it's been set to raw - assume it is deliberately done this way as the user could have alternatively called punk::mode raw or punk::console::enableVirtualTerminal #by not doing this automatically - we assume the caller has a reason. } else { - set rawmode [tsv::get console is_raw] + set rawmode [tsv::get punk_console is_raw] } if {!$rawmode} { @@ -2107,7 +2107,7 @@ proc repl::repl_process_data {inputchan chunktype chunk stdinlines prompt_config set infoprompt [dict get $prompt_config infoprompt] set debugprompt [dict get $prompt_config debugprompt] - set rawmode [tsv::get console is_raw] + set rawmode [tsv::get punk_console is_raw] if {!$rawmode} { #puts stderr "-->got [ansistring VIEW -lf 1 $stdinlines]<--" @@ -2980,7 +2980,7 @@ proc repl::repl_process_data {inputchan chunktype chunk stdinlines prompt_config #after any external command - raw mode as the console sees it can be disabled #set it to match current state of the tsv #---------------------------------------------------------------------------- - if {[tsv::get console is_raw]} { + if {[tsv::get punk_console is_raw]} { if {$::tcl_platform(platform) eq "windows"} { #review #we are in parent process - twapi might not be loaded here - even if it is in the code interp @@ -3437,7 +3437,7 @@ namespace eval repl { return [thread::send %replthread% [list punk::console::vt52 {*}$args]] } proc mode args { - #with tsv::set console is_raw we don't need to call mode in both the replthread and the codethread + #with tsv::set punk_console is_raw we don't need to call mode in both the replthread and the codethread # REVIEW - call in local interp? how about if codethread is safe interp? #interp eval code [list ::punk::console::mode {*}$args] thread::send %replthread% [list punk::console::mode {*}$args] diff --git a/src/modules/punk/repl-buildversion.txt b/src/modules/punk/repl-buildversion.txt index 7b33442f..99bf6678 100644 --- a/src/modules/punk/repl-buildversion.txt +++ b/src/modules/punk/repl-buildversion.txt @@ -1,6 +1,7 @@ -0.2.0 +0.2.1 #First line must be a semantic version number #all other lines are ignored. +#0.2.1 - call-site update for punk::console 0.6.0 tsv array rename: console -> punk_console (is_raw); no behaviour change #0.2.0 - codethread init_script now propagates package prefer via %packageprefer% scriptmap entry #0.2.0 - code interp (punk/0 repltype) now uses punk::lib::interp_sync_package_paths -libunknown 1 instead of manual epoch-only copy, propagating tm list, auto_path, package prefer, and libunknown to the code interp #0.1.6 - repl::init routes default console construction through punk::console::default_console (also gains the probe-byte store unification) diff --git a/src/tests/modules/punk/console/testsuites/console/consolefacts.test b/src/tests/modules/punk/console/testsuites/console/consolefacts.test index 693bde09..982a95b4 100644 --- a/src/tests/modules/punk/console/testsuites/console/consolefacts.test +++ b/src/tests/modules/punk/console/testsuites/console/consolefacts.test @@ -11,7 +11,10 @@ package require opunk::console 999999.0a1.0- #(is_vt52, tabwidth, cell_size, last_da1_result, grapheme_cluster_support, has_bug_*) are keyed #by canonical {in out} channel pair; the process-default console {stdin stdout} stores its facts #in the legacy namespace variables so external readers (punk::repl, textblock, overtype, ...) -#keep working. +#keep working. The store is tsv-backed (G-007): legacy-variable writes are trace-mirrored to tsv, +#non-default consoles store facts only in tsv (owner-qualified keys), and the console ownership +#registry (console_owner_register/get/forget) decides which thread's legacy variables are +#authoritative for the default console. namespace eval ::testspace { namespace import ::tcltest::* @@ -79,7 +82,7 @@ namespace eval ::testspace { }\ -cleanup { set ::punk::console::tabwidth $saved_tabwidth - set ::punk::console::console_facts [dict create] + punk::console::console_fact_clear {chanx chany} }\ -result [list\ 4\ @@ -98,7 +101,7 @@ namespace eval ::testspace { }\ -cleanup [string cat { catch {opunk::console::forget facttest1} - set ::punk::console::console_facts [dict create] + punk::console::console_fact_clear [list $rd $wr] } $pipe_cleanup]\ -result [list\ 5\ @@ -132,7 +135,7 @@ namespace eval ::testspace { -cleanup [string cat { #vt52 on/off toggles process-global colour state - restore set ::punk::console::colour_disabled $saved_colour_disabled - set ::punk::console::console_facts [dict create] + punk::console::console_fact_clear [list $rd $wr] } $pipe_cleanup]\ -result [list\ 0\ @@ -169,7 +172,7 @@ namespace eval ::testspace { set ::env($ev) $val } set ::punk::console::tabwidth $saved_tabwidth - set ::punk::console::console_facts [dict create] + punk::console::console_fact_clear [list $rd $wr] } $pipe_cleanup]\ -result [list\ 1\ @@ -185,7 +188,7 @@ namespace eval ::testspace { lappend result [expr {$::punk::console::cell_size eq ""}] }\ -cleanup [string cat { - set ::punk::console::console_facts [dict create] + punk::console::console_fact_clear [list $rd $wr] } $pipe_cleanup]\ -result [list\ 12x24\ @@ -212,11 +215,11 @@ namespace eval ::testspace { lappend result [expr {$::punk::console::last_da1_result eq $saved_da1}] }\ -cleanup { + punk::console::console_fact_clear [list $rdi $wro] foreach c [list $wri $rdi $wro $rdo] { catch {chan close $c} } array unset ::punk::console::input_chunks_waiting $rdi - set ::punk::console::console_facts [dict create] }\ -result [list\ {1;2}\ @@ -243,7 +246,7 @@ namespace eval ::testspace { }\ -cleanup { set ::punk::console::check::has_bug_zwsp $saved_zwsp - set ::punk::console::console_facts [dict create] + punk::console::console_fact_clear {chanx chany} }\ -result [list\ 1\ @@ -261,12 +264,158 @@ namespace eval ::testspace { lappend result [read $rd] }\ -cleanup [string cat { - set ::punk::console::console_facts [dict create] + punk::console::console_fact_clear [list $rd $wr] } $pipe_cleanup]\ -result [list\ {available 1 mode set}\ {}\ ] + test owner_register_get_forget {ownership registry basics - register/get/forget and stale-owner clearing at consult time}\ + -setup [string cat $common { + set workertid [thread::create {thread::wait}] + }]\ + -body { + #unregistered console reads as empty (operate locally) + lappend result [punk::console::console_owner_get {chanx chany}] + #register defaults to the calling thread + punk::console::console_owner_register {chanx chany} + lappend result [expr {[punk::console::console_owner_get {chanx chany}] eq [thread::id]}] + punk::console::console_owner_forget {chanx chany} + lappend result [punk::console::console_owner_get {chanx chany}] + #explicit owner thread + liveness validation: entry for an exited thread is + #cleared at consult time + punk::console::console_owner_register {chanx chany} $workertid + lappend result [expr {[punk::console::console_owner_get {chanx chany}] eq $workertid}] + thread::release $workertid + #wait for the thread to actually exit + set deadline [expr {[clock milliseconds] + 2000}] + while {[thread::exists $workertid] && [clock milliseconds] < $deadline} { + after 10 + } + lappend result [punk::console::console_owner_get {chanx chany}] + }\ + -cleanup { + catch {punk::console::console_owner_forget {chanx chany}} + catch {thread::release $workertid} + }\ + -result [list\ + {}\ + 1\ + {}\ + 1\ + {}\ + ] + + test owner_anchor_lifecycle {anchoring an opunk::console instance registers the anchoring thread as owner; forget clears the entry}\ + -setup [string cat $pipe_setup { + ::punk::console::ensure_object_integration + }]\ + -body { + opunk::console::create ownertest1 $rd $wr + lappend result [expr {[punk::console::console_owner_get [list $rd $wr]] eq [thread::id]}] + #instance-name spec resolves to the same registry entry + lappend result [expr {[punk::console::console_owner_get ownertest1] eq [thread::id]}] + opunk::console::forget ownertest1 + lappend result [punk::console::console_owner_get [list $rd $wr]] + }\ + -cleanup [string cat { + catch {opunk::console::forget ownertest1} + catch {punk::console::console_owner_forget [list $rd $wr]} + } $pipe_cleanup]\ + -result [list\ + 1\ + 1\ + {}\ + ] + + test owner_default_console_first_wins {a second context anchoring {stdin stdout} neither steals nor releases the default console's ownership}\ + -setup [string cat $common { + ::punk::console::ensure_object_integration + set workertid [thread::create {thread::wait}] + }]\ + -body { + #another live thread owns the default console + punk::console::console_owner_register {stdin stdout} $workertid + #anchoring a local view of the std channels must not take over ownership + opunk::console::create defaultview stdin stdout + lappend result [expr {[punk::console::console_owner_get {stdin stdout}] eq $workertid}] + #and forgetting that local view must not release the owner's entry + opunk::console::forget defaultview + lappend result [expr {[punk::console::console_owner_get {stdin stdout}] eq $workertid}] + }\ + -cleanup { + catch {opunk::console::forget defaultview} + catch {punk::console::console_owner_forget {stdin stdout}} + catch {thread::release $workertid} + }\ + -result [list\ + 1\ + 1\ + ] + + test facts_cross_thread_visibility {default-console facts set (or legacy variables written directly) in the owning thread are visible from another thread; a non-owner set is forwarded to the owner}\ + -setup [string cat $common { + set saved_tabwidth $::punk::console::tabwidth + #this thread is the default console's owner (as the repl thread is in a punk session) + punk::console::console_owner_register {stdin stdout} + 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- + } + }]\ + -body { + #a fact set in the owner is immediately visible in the worker + punk::console::console_fact_set {stdin stdout} tabwidth 11 + lappend result [thread::send $workertid {punk::console::console_fact_get {stdin stdout} tabwidth}] + #a direct legacy-variable write (bypassing console_fact_set) is trace-mirrored + set ::punk::console::tabwidth 13 + lappend result [thread::send $workertid {punk::console::console_fact_get {stdin stdout} tabwidth}] + #the worker's own hollow legacy variable is untouched by the owner's writes + lappend result [thread::send $workertid {set ::punk::console::tabwidth}] + #a non-owner console_fact_set is forwarded to the owning thread (this thread + #services events in the update poll below, as the repl thread does while a + #codethread runs). thread::send executes in the target thread's MAIN interp: in a + #punk session that is where the owner's punk::console lives, so its legacy + #variables sync - but under runtests this testinterp is a child interp, so the + #forwarded write lands in console_fact_set's fallback tsv-mirror path instead + #(nested-interp routing is the interp-alias layer of G-007, a later slice). + #Assert the invariant that holds in both topologies: after a non-owner set the + #value is in the tsv mirror and every non-owner reader sees it. + tsv::set consolefactstest done 0 + thread::send -async $workertid { + punk::console::console_fact_set {stdin stdout} tabwidth 17 + tsv::set consolefactstest done 1 + } + #poll with deadline so a worker-side error fails the test instead of hanging the run + set deadline [expr {[clock milliseconds] + 15000}] + while {![tsv::get consolefactstest done] && [clock milliseconds] < $deadline} { + update + after 10 + } + lappend result [tsv::get consolefactstest done] + lappend result [tsv::get punk_console_facts [list {} stdin stdout tabwidth]] + lappend result [thread::send $workertid {punk::console::console_fact_get {stdin stdout} tabwidth}] + }\ + -cleanup { + catch {thread::release $workertid} + set ::punk::console::tabwidth $saved_tabwidth + punk::console::console_owner_forget {stdin stdout} + catch {tsv::unset consolefactstest} + }\ + -result [list\ + 11\ + 13\ + 8\ + 1\ + 17\ + 17\ + ] + } tcltest::cleanupTests ;#needed to produce test summary. diff --git a/src/tests/modules/punk/console/testsuites/console/consolespec.test b/src/tests/modules/punk/console/testsuites/console/consolespec.test index dc06aad8..29cffb9d 100644 --- a/src/tests/modules/punk/console/testsuites/console/consolespec.test +++ b/src/tests/modules/punk/console/testsuites/console/consolespec.test @@ -481,8 +481,13 @@ namespace eval ::testspace { chan configure $wri -buffering none -translation lf chan configure $wro -buffering none -translation lf chan configure $rdo -blocking 0 -translation lf - set saved_cache $::punk::console::dec_has_mode_cache - set ::punk::console::dec_has_mode_cache [dict create] + #the mode cache lives in tsv keyed {dectype qualifier in out passthrough mode} - + #clear any entries for this channel pair (names can be reused across [chan pipe] calls) + foreach k [tsv::array names punk_console_modecache] { + if {[lindex $k 2] eq $rdi && [lindex $k 3] eq $wro} { + catch {tsv::unset punk_console_modecache $k} + } + } }\ -body { opunk::console::create cachetest1 $rdi $wro @@ -500,7 +505,11 @@ namespace eval ::testspace { lappend result [read $rdo] }\ -cleanup { - set ::punk::console::dec_has_mode_cache $saved_cache + foreach k [tsv::array names punk_console_modecache] { + if {[lindex $k 2] eq $rdi && [lindex $k 3] eq $wro} { + catch {tsv::unset punk_console_modecache $k} + } + } catch {opunk::console::forget cachetest1} foreach c [list $wri $rdi $wro $rdo] { catch {chan close $c} diff --git a/src/tests/modules/punk/console/testsuites/console/emitconsole.test b/src/tests/modules/punk/console/testsuites/console/emitconsole.test index 370d7e80..f481e0fa 100644 --- a/src/tests/modules/punk/console/testsuites/console/emitconsole.test +++ b/src/tests/modules/punk/console/testsuites/console/emitconsole.test @@ -142,7 +142,7 @@ namespace eval ::testspace { lappend result [expr {[read_emitted $rd] eq [punk::ansi::cursor_off]}] }\ -cleanup [string cat { - set ::punk::console::console_facts [dict create] + punk::console::console_fact_clear [list $rd $wr] } $pipe_cleanup]\ -result [list\ 1\