Skip to content

Commit 9cae7b1

Browse files
committed
fix: engine C-API's active-engine/callback globals were not thread-local, letting concurrent engines cross-contaminate
Follow-up to verifying an external Tengo-comparison report's concurrency concern: engine.zig's g_active_engine/write_callback/read_callback and host_abi.zig's native_host_call_fn/native_host_call_ctx were plain process-global vars, unlike the 8 internal Runtime.activate()-pinned state pointers (chunk/globals/heap/vm/tasks/fs/net/http), which are already threadlocal. pushEngineState/popEngineState overwrite these 5 fields for the duration of every engine_run/engine_call. Two threads calling into two different engines concurrently (an officially-supported pattern per docs/embedding.md) could interleave those writes, so one engine's script ran with the other engine's write/read callback, or the other engine's host-call function paired with the other engine's ctx pointer -- a real type-confusion hazard, since callbacks @ptrCast/@aligncast that ctx back to their own expected struct type. Confirmed with a real two-thread repro (added as a regression test) before fixing: it reliably produced wrong results and even corrupted an unrelated later test in the same process. Fixed by making all 5 fields threadlocal. Verified under standard, -Dpreset=stress, and -Dgc_stress=true builds.
1 parent 978cf3a commit 9cae7b1

3 files changed

Lines changed: 155 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,49 @@ This changelog tracks notable language/runtime changes by implementation date.
44

55
## 2026-08-21
66

7+
### Fix — cross-thread engine C-API race: wrong callback (and ctx) could serve the wrong engine
8+
9+
Follow-up to an independent LLM's Tengo-vs-Gengo comparison, which flagged
10+
that `src/engine.zig`'s C-API layer holds process-global state distinct
11+
from the (already thread-local) internal `Runtime` state. Verified this is
12+
real and exploitable, not just theoretical.
13+
14+
`engine.zig`'s `g_active_engine`/`write_callback`/`read_callback` and
15+
`host_abi.zig`'s `native_host_call_fn`/`native_host_call_ctx` were plain
16+
(non-thread-local) `var`s, overwritten by `pushEngineState`/`popEngineState`
17+
for the duration of every `engine_run`/`engine_call`. Two threads calling
18+
into two *different* engines concurrently — an officially-supported usage
19+
pattern per `docs/embedding.md` ("one runtime per thread... may be used
20+
independently, including concurrently from different threads") — could
21+
interleave those writes, so one engine's script ran with the *other*
22+
engine's write/read callback, or (via `host_abi.setNativeHostCall`) the
23+
other engine's host-call function paired with the other engine's `ctx` — a
24+
real type-confusion hazard, since a host callback `@ptrCast`/`@alignCast`s
25+
that `ctx` back to its own expected struct type.
26+
27+
Confirmed with a real two-thread repro before fixing: two engines with
28+
distinct host-call contexts, hammered concurrently via `engine_run`/
29+
`engine_call`, reliably produced wrong results (one engine's calls computed
30+
using the other engine's context) — and even corrupted an unrelated,
31+
later-running test in the same process, demonstrating how far the
32+
contamination reaches. All 8 of the *internal* `Runtime.activate()`-pinned
33+
state pointers (chunk/globals/heap/vm/tasks/fs/net/http) were already
34+
`threadlocal`, so this was scoped to exactly these 5 fields. Fixed by
35+
making all 5 `threadlocal`, matching the existing pattern; added a
36+
regression test (`engine.zig`, two real OS threads racing two engines'
37+
host-call callbacks) that fails reliably on the old code and passes
38+
deterministically on the fix. Verified under standard, `-Dpreset=stress`,
39+
and `-Dgc_stress=true` builds.
40+
41+
Separately (not fixed, out of scope for this pass): `io.zig`'s own trace
42+
state (`g_trace_fn`/`g_trace_userdata`/`g_trace_handle`) is the same class
43+
of non-thread-local global, and `engine.zig`'s native test suite
44+
(`zig build engine-api-test`) already has one pre-existing, order-dependent
45+
flaky test (`engine_set_trace_fn fires per source line`) caused by it —
46+
confirmed present on a clean checkout, unrelated to this fix. That test
47+
suite is also not currently wired into `zig build test` or the pre-push
48+
hook, so it isn't exercised by CI at all.
49+
750
### Audit — duplication/DRY sweep across native modules, VM arithmetic, and the compiler
851

952
Four parallel audits (VM/opcode layer, native capability modules, compiler

src/engine.zig

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,29 @@ const HostCallCallback = host_abi.NativeHostCallFn;
4242
// Active-engine state — set for the duration of engine_run / engine_call so that
4343
// write/read callbacks and capability handlers resolve to the calling engine's
4444
// per-instance configuration rather than a process-global slot.
45-
var g_active_engine: ?*Engine = null;
45+
//
46+
// threadlocal, not a plain process-global: the internal Runtime state these
47+
// wrap (chunk/globals/heap/vm/tasks/fs/net/http, all pinned by
48+
// Runtime.activate() below) is already threadlocal for exactly this reason.
49+
// Before this was threadlocal, two threads calling engine_run/engine_call on
50+
// two DIFFERENT engines concurrently could interleave pushEngineState's
51+
// writes, so one engine's script ran with the OTHER engine's write/read
52+
// callback (or, via host_abi.setNativeHostCall below, the other engine's
53+
// host-call function AND ctx) for the remainder of its execution — silent
54+
// misrouting at best (output/reads crossing engines), real type confusion
55+
// at worst (a host callback invoked with a foreign ctx it @ptrCast/@alignCast
56+
// s to its own expected type). Confirmed via a real two-thread repro (see
57+
// "two engines' host-call callbacks stay isolated..." below) before this
58+
// fix: it failed, and even corrupted an unrelated later test in the same
59+
// process. docs/embedding.md already promises "one runtime per thread...
60+
// active-runtime tracking is thread-local" — this makes that true for the
61+
// whole C-API surface, not just the internal Runtime layer.
62+
threadlocal var g_active_engine: ?*Engine = null;
4663

4764
// Process-global active slots — overwritten by pushEngineState for each ABI
4865
// operation and restored by popEngineState on return.
49-
var write_callback: ?WriteCallback = null;
50-
var read_callback: ?ReadCallback = null;
66+
threadlocal var write_callback: ?WriteCallback = null;
67+
threadlocal var read_callback: ?ReadCallback = null;
5168

5269
const ImportLoaderFn = *const fn (
5370
ctx: ?*anyopaque,
@@ -1816,6 +1833,88 @@ test "native host modules dispatch through the registered callback" {
18161833
try std.testing.expectEqual(@as(usize, 2), context.module_calls);
18171834
}
18181835

1836+
const HostCallRaceContext = struct {
1837+
factor: i64,
1838+
1839+
fn callback(context: ?*anyopaque, id: u16, args: [*]const ValueWire, argc: u16, out: *ValueWire) callconv(.c) i32 {
1840+
const self = @as(*@This(), @ptrCast(@alignCast(context.?)));
1841+
switch (id) {
1842+
@intFromEnum(host_abi.HostCall.abi_version) => {
1843+
out.* = .{ .tag = @intFromEnum(WireTag.number), .flags = 0, .reserved = 0, .payload = @bitCast(@as(f64, @floatFromInt(host_abi.ABI_VERSION))), .len = 0, .reserved2 = 0 };
1844+
return @intFromEnum(host_abi.CallStatus.ok);
1845+
},
1846+
HostModuleCallIdBase => {
1847+
if (argc != 1 or args[0].tag != @intFromEnum(WireTag.number) or (args[0].flags & host_abi.FLAG_INTEGER) == 0) return @intFromEnum(host_abi.CallStatus.bad_args);
1848+
const value: i64 = @bitCast(args[0].payload);
1849+
out.* = .{ .tag = @intFromEnum(WireTag.number), .flags = host_abi.FLAG_INTEGER, .reserved = 0, .payload = @bitCast(value * self.factor), .len = 0, .reserved2 = 0 };
1850+
return @intFromEnum(host_abi.CallStatus.ok);
1851+
},
1852+
else => return @intFromEnum(host_abi.CallStatus.unsupported),
1853+
}
1854+
}
1855+
};
1856+
1857+
// Worker for the host-call cross-thread race test below: engine.zig's
1858+
// g_active_engine/write_callback/read_callback (this file) and
1859+
// host_abi.zig's native_host_call_fn/native_host_call_ctx are plain (not
1860+
// threadlocal) globals, overwritten by pushEngineState/popEngineState for
1861+
// the duration of every engine_run/engine_call. Two threads calling those
1862+
// entry points on two DIFFERENT engines concurrently can interleave those
1863+
// writes, so one engine's host-call callback runs with the OTHER engine's
1864+
// ctx pointer — every call below multiplies by this worker's own `factor`,
1865+
// so a wrong (foreign) ctx surfaces as a wrong product, not a crash.
1866+
fn hostCallRaceWorker(handle: i32, factor: i64, iterations: usize, failed: *std.atomic.Value(bool)) void {
1867+
var context: HostCallRaceContext = .{ .factor = factor };
1868+
if (engine_set_host_call_fn(handle, HostCallRaceContext.callback, &context) != 0) {
1869+
failed.store(true, .seq_cst);
1870+
return;
1871+
}
1872+
const funcs = [_]HostModuleFuncDef{.{ .name_ptr = @intFromPtr("mul".ptr), .name_len = 3, .arity = 1 }};
1873+
if (engine_register_module(handle, @intFromPtr("m".ptr), 1, @intFromPtr(&funcs), funcs.len) != 0) {
1874+
failed.store(true, .seq_cst);
1875+
return;
1876+
}
1877+
const source =
1878+
\\m := import("host:m")
1879+
\\pub func answer(x int) int { return m.mul(x) }
1880+
;
1881+
if (engine_run(handle, @intFromPtr(source.ptr), source.len) != 0) {
1882+
failed.store(true, .seq_cst);
1883+
return;
1884+
}
1885+
var i: usize = 0;
1886+
while (i < iterations) : (i += 1) {
1887+
var args = [_]ValueWire{.{ .tag = @intFromEnum(WireTag.number), .flags = host_abi.FLAG_INTEGER, .reserved = 0, .payload = @bitCast(@as(i64, 7)), .len = 0, .reserved2 = 0 }};
1888+
var out: ValueWire = undefined;
1889+
if (engine_call(handle, @intFromPtr("answer".ptr), 6, @intFromPtr(&args), 1, @intFromPtr(&out)) != 0) {
1890+
failed.store(true, .seq_cst);
1891+
return;
1892+
}
1893+
const got: i64 = @bitCast(out.payload);
1894+
if (got != 7 * factor) {
1895+
failed.store(true, .seq_cst);
1896+
return;
1897+
}
1898+
}
1899+
}
1900+
1901+
test "two engines' host-call callbacks stay isolated when engine_run/engine_call race across threads" {
1902+
if (comptime is_wasm) return;
1903+
const handleA = engine_init();
1904+
try std.testing.expect(handleA > 0);
1905+
defer engine_destroy(handleA);
1906+
const handleB = engine_init();
1907+
try std.testing.expect(handleB > 0);
1908+
defer engine_destroy(handleB);
1909+
1910+
var failed = std.atomic.Value(bool).init(false);
1911+
const t1 = try std.Thread.spawn(.{}, hostCallRaceWorker, .{ handleA, @as(i64, 2), @as(usize, 2000), &failed });
1912+
const t2 = try std.Thread.spawn(.{}, hostCallRaceWorker, .{ handleB, @as(i64, 3), @as(usize, 2000), &failed });
1913+
t1.join();
1914+
t2.join();
1915+
try std.testing.expect(!failed.load(.seq_cst));
1916+
}
1917+
18191918
test "engine_call: recover() in defer intercepts panic" {
18201919
// Regression test: core.recover() inside a deferred function
18211920
// must intercept panics even when the function is called via engine_call

src/runtime/host_abi.zig

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,16 @@ pub const NativeHostCallFn = *const fn (
4040
out: *ValueWire,
4141
) callconv(.c) i32;
4242

43-
var native_host_call_fn: ?NativeHostCallFn = null;
44-
var native_host_call_ctx: ?*anyopaque = null;
43+
// threadlocal: set together by engine.zig's pushEngineState/popEngineState
44+
// for the duration of one engine_run/engine_call. As plain (non-threadlocal)
45+
// vars these two writes could tear across two threads calling into
46+
// different engines concurrently — engine B's callback function paired with
47+
// engine A's ctx pointer, a real type-confusion hazard since callbacks
48+
// @ptrCast/@alignCast ctx back to their own expected struct type. See
49+
// engine.zig's g_active_engine doc comment for the full writeup and the
50+
// two-thread repro that caught it.
51+
threadlocal var native_host_call_fn: ?NativeHostCallFn = null;
52+
threadlocal var native_host_call_ctx: ?*anyopaque = null;
4553

4654
pub fn setNativeHostCall(fn_ptr: ?NativeHostCallFn, ctx: ?*anyopaque) void {
4755
native_host_call_fn = fn_ptr;

0 commit comments

Comments
 (0)