Skip to content

contrib.host.tcl: the #904 bidirectional tier - #3493

Merged
paul-hammant merged 1 commit into
mainfrom
feat/host-tcl-bidirectional
Oct 31, 2026
Merged

paul-hammant merged 1 commit into
mainfrom
feat/host-tcl-bidirectional

Conversation

@paul-hammant

Copy link
Copy Markdown
Collaborator

contrib.host.tcl gets the #904 bidirectional tier that Lua and Duktape already have:

  • a persistent VM that is its own interpreter;
  • Aether @c_callbacks registered as Tcl commands;
  • global setters;
  • typed arguments, returns and eval results.

It ticks Tcl off contrib/host/TODO.md. It is also groundwork for the sandboxed-child design: there, a guest calls the host and the host calls back into the guest, and that is what nested evals here test.

API

tcl.vm_new / vm_free / vm_register / vm_set_global_str|int|strlist / vm_eval / vm_result_type|int|double|str|bool / vm_pop_result / vm_set_instruction_limit / vm_set_time_limit. Inside a callback: tcl.arg_count|type|int|double|str|bool, tcl.push_int|double|str|bool|nil and tcl.raise_error.

The tags are TCL_TNIL … TCL_TOTHER as in Lua and Duktape, plus TCL_TFLOAT.

A command is registered under the name it is given, unchanged. "app.add" is called as app.add 2 3, and "app::add" lands in the namespace ::app.

What is particular to Tcl

  • The eval compiles the script whole (Tcl_EvalObjEx). Tcl checks its limits only inside compiled code, every few dozen instructions. Run as a string (Tcl_EvalEx), a top-level while 1 {incr i} executes a command at a time, and each pass of its body is a compiled run too short to reach a check. So the command limit never fired. Plain Tcl, without the bridge, hangs the same way.
  • Two guards. vm_set_instruction_limit counts Tcl commands, so it cannot stop a loop that runs none, such as while 1 {}, which compiles to jumps. vm_set_time_limit can. Both apply per outermost eval, and a script cannot catch either one. An eval nested in a callback runs inside the outer budget rather than renewing it.
  • Nested evals. A callback may evaluate on its own VM.
    • The nested eval runs at global level, not in the frame of the proc that called the callback.
    • The outer call's arguments survive it.
    • At the outermost level, Tcl itself turns return into success and a stray break into an error. For a nested eval the bridge does that, reading the return options. return -code error, return -level 2 and codes Tcl doesn't know come out as Tcl reports them at the top.
  • Threads. An interpreter belongs to the thread that created it. Every vm_* call from another thread is refused without touching Tcl: vm_eval returns -1, and the error result says why.
  • Every value is a string. A value's tag comes from its internal representation: expr {2+3} is an int, but the literal 5 in set x 5 is a string. The readers convert either way.
    • A bignum, or a double out of range, saturates when read as an int. NaN and non-numbers read 0.
    • push_nil returns the empty string, which reads as TCL_TNIL. push_bool returns 1 or 0.
  • Portability across 8.6 and 9.0. The bridge keeps its dlopen model.
    • Reference counts go through Tcl_DbIncrRefCount / Tcl_DbDecrRefCount. The Tcl_IncrRefCount macros write a field that 9.0 widened.
    • The one field read directly is Tcl_Obj.typePtr. It is three pointers in on both versions, and a _Static_assert holds the bridge to that.

Tests

  • contrib/host/tcl/test_host_tcl.ae: a co-located std.spec run by make contrib-host-check, 3 cases.
  • tests/integration/host_tcl_bidirectional/: a C harness, shaped like Duktape's, covering:
    • callbacks and every type both ways, including non-ASCII;
    • errors;
    • nested evals and their return codes;
    • both limits;
    • the other-thread refusal;
    • isolation between two VMs;
    • memory staying flat. It runs three loops of 20,000 rounds each, and the smaller of the last two growths must stay under 128 kB.
Defect planted in a copy of the bridge Caught by
a held reference not released memory: about 13 MB per 20,000 rounds
eval as a string (Tcl_EvalEx) the command limit never fires (timeout)
not at global level nested eval sees info level 1
callback frame not restored nested: the outer argument reads 0
a nested eval re-arms the limits while 1 {host.nest_small} never ends (timeout)
limits not disarmed the time-limit case trips the stale command limit
no thread check the other thread's eval runs
bignum reads 0 the saturation case
a nested break succeeds nested return codes
return options ignored nested return codes
return level ignored nested return -level 2
budget not per eval the second round within budget is refused
raise ignored caught-raise case
return count ignored a callback returning 0 returns nothing
empty string reads as a string the nil case
a double tagged int the double case

One more mutant survived: treating every non-ok code as an error. It is equivalent to the original, because by that line the code has already been reduced to ok or error. Two of the mutants above, built into the archive, also fail the Aether spec: the raise case fails 3 assertions, and the eval-as-string case hangs.

Checked

  • The C harness passes in about 1.4 s, and valgrind reports 0 errors. Leaks are covered by the memory check rather than valgrind, because Tcl keeps freed objects on its own free list.
  • make contrib-host-check passes; tcl is OK and PASS (built + ran), and its spec gives 3 passing.
  • ae fmt --check, check-docs, check-changelog, check-tests, check-warnings, check-raw-calls, check-extern-widths and check-standalone pass.

Not run: the full make test and make test-ae, since the runtime is untouched and the new test was run on its own. Tcl 9.0 is also untested; this machine has 8.6.13.

🤖 Generated with Claude Code

A persistent VM per interpreter, Aether callbacks registered as Tcl
commands, global setters, typed arguments and returns, and typed eval
results, in the shape Lua and Duktape already have, plus a TCL_TFLOAT
tag. Particular to Tcl:

- The eval compiles the script whole (Tcl_EvalObjEx). Tcl checks its
  limits only in compiled code, and a top-level `while 1 {incr i}` run
  command by command (Tcl_EvalEx) never reached a check.
- Two guards: the command limit, and a time limit for loops that run no
  commands (`while 1 {}`). Both are per outermost eval; a callback's
  nested eval runs inside them rather than renewing them.
- A callback may evaluate on its own VM: at global level, with the outer
  call's arguments kept, and with `return`, `return -code error`, deep
  returns and stray `break` read as Tcl reads them at the top.
- A VM is refused from any thread but its creator's, without touching Tcl.
- Reference counts go through Tcl_DbIncrRefCount/DecrRefCount; the one
  field read directly, Tcl_Obj.typePtr, sits where 8.6 and 9.0 both put it.

Tests: the co-located std.spec (contrib-host-check) and a C harness in
tests/integration/host_tcl_bidirectional/. 17 planted defects, 16 caught;
the survivor is equivalent to the original.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@paul-hammant
paul-hammant merged commit b13a57e into main Oct 31, 2026
44 checks passed
@paul-hammant
paul-hammant deleted the feat/host-tcl-bidirectional branch October 31, 2026 10:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant