You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

13 KiB

G-002 Non-nested subshell with console targeting and inter-subshell comms

Status: proposed Scope: src/modules/punk/repl-999999.0a1.0.tm, src/modules/punk/repl/codethread-999999.0a1.0.tm Goal: a subshell can target a named console (default or non-default) and run without blocking the parent, replacing the synchronous nested interp-eval model. Acceptance: a parent REPL launches a subshell against a named console and continues processing its own input while the subshell runs; the parent can signal/query the running subshell; thread::send -async dispatched from within the subshell's code interp arrives at that interp (so packages like promise work when thread features aren't disabled); the "first subshell asymmetry" TODO at repl-999999.0a1.0.tm:3130 is resolved; existing synchronous subshell punk/safe/safebase/punksafe behaviour is preserved as a default mode.

Context

The ::subshell command (src/modules/punk/repl-999999.0a1.0.tm:4147-4155) is an alias to ::repl::interphelpers::subshell_ensemble. The ensemble has four procs (punk, safe, safebase, punksafe, lines 3514-3560) and every one of them launches a subshell the same way:

set replresult [interp eval code {
    package require punk::repl
    repl::init -type punk
    repl::start stdin
}]

Three properties follow from this shape:

  1. Always nested. The subshell runs in a child interpreter named code created earlier in repl::init (lines 3575-3620). The parent's call to interp eval code { ... } is synchronous and blocks until the child REPL exits.
  2. Always stdin. repl::start stdin hardcodes the parent's stdin as the subshell's input. There is no way to point a subshell at a different console.
  3. First-subshell asymmetry. The TODO at lines 3130-3132 notes the first subshell runs differently from subsequent nested ones, and that resolving this matters for "control aspects of the code interp such as cpu/memory resource limits and sandboxing" and for "consistency for how thread calls are routed to the parent interp vs a child interp."

A second TODO at lines 3135-3139 is directly on-point for non-blocking communication:

"investigate whether Tcl code or thread extension code is responsible for routing thread::send -async calls. We want to be able to control which interp receives the call. If we can't do this in pure tcl, then first investigate if we can do it purely in an alternative thread extension. Such an extension should be based closely on the existing thread extension, with minimal changes to allow us to control which interp receives the call, the intention being to see if the changes can be made unobtrusive and backwards compatible so that it has a chance of being accepted into the mainline thread extension."

The primitives for non-blocking inter-thread state already exist in codethread-999999.0a1.0.tm: tsv::set/tsv::get for shared state and thread::cond notify/thread::wait for signalling. They are used between the codethread and the repl thread but are not wired to the subshell command. The subshell is a purely nested, synchronous structure today.

This goal depends on G-001's launch-time console selection: "target a named console" requires the REPL to accept a non-default console spec, which G-001 adds.

Approach

Split the subshell launch into layers. The canonical launch API (section 0) is a prerequisite for the others — the asymmetry fix (section 4) and the non-blocking launch (section 2) both depend on a single dispatch owning code-interp creation.

  1. Canonical launch API. Make subshell the single public entry point for starting a REPL; demote repl::init+repl::start to internal implementation that only subshell's dispatch calls. The interp alias mechanism — already in use at repl-999999.0a1.0.tm:4155 (code alias subshell ::repl::interphelpers::subshell_ensemble) — becomes the context signal, not caller introspection:

    • At root: subshell is a real proc (or aliased to the same dispatch in the root interp). The dispatch creates the first code interp, installs the subshell alias in it pointing back to the dispatch, and runs the REPL in code.
    • Inside a code interp: subshell is the alias installed at creation time. Calling it runs the dispatch in the parent, which creates a nested code interp (sub-interp of the calling code), installs the same alias in the nested interp, and runs the REPL there.
    • The caller's code is identical in both cases (subshell punk, subshell safe, etc.). The caller never introspects "am I in a code interp?" — the alias is the answer. Safe interps simply don't get the alias (or get a restricted one permitting only configured launch types), so they can't launch unrestricted subshells and can't ascertain their nesting level.
    • Pre-configuration (-console, -limits, -sandbox) passes through the same API at every level; the dispatch applies it at code-interp creation time (see G-003). The existing subshell punk/safe/safebase/punksafe ensemble procs become type presets that set -type and default -sandbox; option keywords (-console, -limits, -sandbox, later -async) layer on top.
    • Design constraint: the dispatch runs in the parent interp (via alias), so the parent must be responsive enough to service the alias call. In the synchronous-nested model today the parent is blocked in interp eval anyway. For the non-blocking model (section 2), the parent's event loop must reach the alias dispatch — this ties into the thread::send -async routing concern and must not be made unreachable by the non-blocking design.
  2. Console targeting. repl::init accepts a -console spec (channel pair, anchored instance name, or ::opunk::Console object value) resolved via punk::console::console_spec_resolve. The subshell ensemble procs pass through a -console argument instead of hardcoding stdin. This is the G-001 seam extended into the subshell command.

  3. Non-blocking launch. Replace the synchronous interp eval code { repl::start stdin } with an asynchronous dispatch that returns control to the parent REPL. Two implementation directions to investigate (the user's TODO at 3135-3139 already frames this as an investigation):

    • Pure-Tcl: keep the child interp in a separate thread and use thread::send -async with a -callback to the parent interp. The parent's REPL loop must yield to its event loop between inputs so the callback can be delivered. This requires the parent to control which interp receives the async send, which the TODO flags as the open question.
    • Thread-extension variant: if pure-Tcl routing of thread::send -async to a chosen interp isn't possible, build a minimally-modified thread extension (per the TODO's mainline-acceptance criterion) and gate it behind a package require with fallback to the synchronous mode.

    Hard constraint — standard thread semantics must be preserved. Any non-blocking launch solution must keep thread::send -async working as it does in a plain tclsh session for code running inside the subshell's code interp. The standard Tcl Thread package (written in C, maintained by the Tcl core team) routes thread::send -async messages to the root interpreter of the target thread, not to a named sub-interp; this is documented in-code at codethread-999999.0a1.0.tm:112-113 ("expecting to be called from a thread::send in parent repl - ie in the toplevel interp so that the sub-interp 'code' is available"). Packages like promise (vendored at src/vendormodules/promise-1.2.0.tm) rely on thread::send -async callbacks arriving at the interp that dispatched them; if the message lands at the root interp instead of the code interp, promise and similar packages break. The investigation must therefore answer: can thread::send -async be routed to a chosen sub-interp in pure Tcl, or does the Thread package (and potentially Tcl itself) need modification?

    • If a customised Thread package is required: the modification is written in C, based closely on the upstream Thread extension, with minimal changes to allow controlling which interp receives the call. The existing TODO at repl:3135-3139 sets a mainline-acceptance criterion — the changes should be unobtrusive and backwards-compatible enough to have a chance of being accepted upstream. If this path is taken, create a follow-up goal for the fork's maintenance and upstreaming; do not let it grow inside G-002.
    • If Tcl itself needs patching: same criterion — minimal, upstreamable. A separate follow-up goal should be created for the Tcl patch's maintenance if this path is taken.
  4. Inter-subshell communication. Build on the existing tsv::/thread::cond primitives already in codethread-999999.0a1.0.tm. A running subshell registers a well-known tsv:: key (keyed by subshell id); the parent signals/queries via tsv:: and thread::cond. The exact message vocabulary is deferred to implementation — this goal's acceptance is only that the parent can signal/query a running subshell, not a full RPC protocol.

  5. First-subshell asymmetry. Resolve TODO at repl:3130 by making the first subshell use the same code-interp launch path as subsequent ones. The asymmetry today is that the first subshell's code interp is created lazily/implicitly; subsequent ones reuse the pattern. The fix is a consequence of the canonical launch API (section 0): root and nested both go through the same dispatch, so there is no longer a "first subshell is special" path — the only branch in the dispatch is whether to create a top-level code or a nested one, which is one branch, not a separate code path.

  6. Default mode preserved. The existing synchronous subshell punk/safe/safebase/punksafe behaviour stays available as a default (no -console/-async flags) so existing scripts and muscle memory don't break. The new behaviour is opt-in via flags.

Alternatives considered

  • Always-async, drop synchronous mode. Rejected: backward compatibility for existing scripts and the existing subshell punk UX matters. The acceptance criterion explicitly preserves synchronous as the default.
  • Coroutines instead of threads for non-blocking. Rejected for now: the subshell runs in a child interp, and coroutines don't cross interp boundaries. Threads are already in use (codethread, %replthread%), so extending the thread model is consistent. Coroutines could be revisited if the thread-routing TODO finds no pure-Tcl answer.
  • Separate goal for inter-subshell comms. Considered (the split analysis proposed this as a possible G-003). Deferred: the user framed non-blocking launch and inter-subshell comms together, and the comms vocabulary depends on the launch model chosen. Splitting now risks an orphan goal whose acceptance can't be written until the launch model is fixed. Revisit after G-002's approach firms up.
  • Resolve the thread-routing TODO before writing this goal. Rejected: the TODO is an investigation question, not a prerequisite. The goal's acceptance is satisfied by whichever implementation direction the investigation settles on; the goal itself is stable either way.

Notes

  • Depends on G-001's launch-time console selection - satisfied: G-001 achieved 2026-07-11 (see goals/archive/G-001-pluggable-console-backends.md), the prerequisite is in place.
  • The thread::send -async routing problem is the hardest open question in G-002. The standard Thread package targets the root interp of the receiving thread; the code sub-interp is where subshell code actually runs, so async messages miss it. promise (vendored at src/vendormodules/promise-1.2.0.tm) is the concrete canary — if promise works inside a subshell, the routing is correct.
  • The TODO at repl:3135-3139 frames the thread-extension investigation with a mainline-acceptance criterion. If that investigation concludes a fork is needed (customised Thread package and/or Tcl patch), create a follow-up goal for the fork's maintenance and upstreaming; do not let it grow inside G-002. The fork itself is out of scope for G-002's acceptance — G-002 is satisfied by whichever routing solution is found, not by the fork existing.
  • tsv:: and thread::cond are already used in codethread-999999.0a1.0.tm (lines 203-218) for cross-thread result/status handoff. The inter-subshell comms layer should reuse the same primitives rather than introducing a new message bus.
  • The "first subshell asymmetry" fix touches the same code interp creation path used by the synchronous mode, so it should be done first (it's the smallest piece and unblocks the others).
  • No persisted prior chat on this topic was found in project sessions; the motivation comes from the existing TODOs at repl:3130-3139 and the user's stated intent.