Starting to dogfood this. Many issues fixed.

This commit is contained in:
Scott Duensing 2026-07-24 23:03:13 -05:00
parent 05cf86cc9a
commit fe0fe73884
35 changed files with 2280 additions and 97 deletions

9
API.md
View file

@ -14,6 +14,12 @@ etc. -- see the README and `src/calog.h`.)
- **Output & exit** (provided by the `calog` runner): `calogPrint(...)` writes to stdout;
`calogExit([code])` tears everything down and exits. calog is event-driven, so a script
must call `calogExit` (or be interrupted) to end -- a finished top level does not exit.
`calogExit` **does not return**: it stops the calling script right there (the statement after
it never runs), and every other live script stops at its next native call. So the first
`calogExit` decides the process's status -- a `calogExit(1)` on a failed check cannot be
followed by more work, nor overwritten by a later `calogExit(0)`.
`calogEnd([code])` ends **only the calling script**, the same way: it does not return, its context
is reaped, and the other scripts carry on. When the last one ends, `calog` exits.
- **Values.** Arguments and results marshal through one canonical type: `nil`, `bool`, `int`,
`real`, `string`, `list`, and `map` (keyed record). Strings are **binary-safe** (may contain
embedded NULs) everywhere the underlying library allows it.
@ -107,7 +113,8 @@ egress (JavaScript, Berry, s7, mruby); Janet loses `symbol`/`keyword` subtype (i
| Function | Description |
|---|---|
| `calogPrint(...values: any)` | Write each argument to stdout, space-separated, with a trailing newline. |
| `calogExit([code: int])` | Tear down the runtime and exit the process with `code` (default `0`). Does not return. |
| `calogEnd([code: int])` | End **this script only**, leaving every other script running. **Does not return** -- it unwinds the calling script at the call site. Its context is then reaped, and once every launched script has ended, `calog` exits. A `code` given here names the process's status like `calogExit`'s does (first request wins); omitting it claims nothing. |
| `calogExit([code: int])` | Tear down the runtime and exit the process with `code` (default `0`). **Does not return** -- it unwinds the calling script at the call site, so nothing after it runs, and stops every other live script at its next native call. The first caller's `code` is the one reported; a script that catches the unwind still cannot call another native. |
## archive

View file

@ -340,7 +340,7 @@ $(PCRE2LIB):
BINS = bin/testBroker bin/testLua bin/testMyBasic bin/testPolyglot bin/testActor bin/testHooks \
bin/testEngineLua bin/testEngineMyBasic bin/testSquirrel bin/testEngineSquirrel bin/testJs bin/testEngineJs \
bin/testEngineBerry bin/testEngineS7 bin/testEngineWren bin/testEngineMruby bin/testEngineTcl bin/testEngineJanet bin/testLoad bin/testDb bin/testNet bin/testTask bin/testExport bin/testJson bin/testXml bin/testTime bin/testFs bin/testCrypto bin/testKv bin/testTimer bin/testPubsub bin/testHttp bin/testHttps bin/testArchive bin/testUtil bin/testSandbox bin/testTrace bin/testHttpdLua bin/embed bin/calog
bin/testEngineBerry bin/testEngineS7 bin/testEngineWren bin/testEngineMruby bin/testEngineTcl bin/testEngineJanet bin/testLoad bin/testDb bin/testNet bin/testTask bin/testExport bin/testJson bin/testXml bin/testTime bin/testFs bin/testCrypto bin/testKv bin/testTimer bin/testPubsub bin/testHttp bin/testHttps bin/testArchive bin/testUtil bin/testSandbox bin/testExit bin/testTeardown bin/testTrace bin/testHttpdLua bin/embed bin/calog
all: $(BINS)
@ -352,7 +352,7 @@ $(STRICTOBJ): obj/%.o: %.c | obj
$(CC) $(COREFLAGS) $(INC) -c -o $@ $<
# strict C, threaded
THREADOBJ = obj/context.o obj/mybasicEngine.o obj/testActor.o obj/testEngineLua.o obj/testEngineSquirrel.o obj/testEngineJs.o obj/testEngineMyBasic.o obj/testEngineBerry.o obj/testEngineS7.o obj/testEngineWren.o obj/testEngineMruby.o obj/testEngineTcl.o obj/testEngineJanet.o obj/calogHandle.o obj/testDb.o obj/testNet.o obj/testTask.o obj/calogExport.o obj/testExport.o obj/calogJson.o obj/testJson.o obj/calogFs.o obj/testFs.o obj/calogTime.o obj/testTime.o obj/calogKv.o obj/testKv.o obj/testCrypto.o obj/calogTimer.o obj/testTimer.o obj/calogPubsub.o obj/testPubsub.o obj/testHttp.o obj/testSsh.o obj/calogCsv.o obj/calogProc.o obj/testSandbox.o obj/testTrace.o obj/testHttpdLua.o obj/testHooks.o
THREADOBJ = obj/context.o obj/mybasicEngine.o obj/testActor.o obj/testEngineLua.o obj/testEngineSquirrel.o obj/testEngineJs.o obj/testEngineMyBasic.o obj/testEngineBerry.o obj/testEngineS7.o obj/testEngineWren.o obj/testEngineMruby.o obj/testEngineTcl.o obj/testEngineJanet.o obj/calogHandle.o obj/testDb.o obj/testNet.o obj/testTask.o obj/calogExport.o obj/testExport.o obj/calogJson.o obj/testJson.o obj/calogFs.o obj/testFs.o obj/calogTime.o obj/testTime.o obj/calogKv.o obj/testKv.o obj/testCrypto.o obj/calogTimer.o obj/testTimer.o obj/calogPubsub.o obj/testPubsub.o obj/testHttp.o obj/testSsh.o obj/calogCsv.o obj/calogProc.o obj/testSandbox.o obj/testExit.o obj/testTeardown.o obj/testTrace.o obj/testHttpdLua.o obj/testHooks.o
$(THREADOBJ): obj/%.o: %.c | obj
$(CC) $(COREFLAGS) $(INC) -pthread -c -o $@ $<
@ -741,6 +741,15 @@ bin/testUtil: obj/testUtil.o obj/calogCsv.o obj/calogProc.o obj/calogRegex.o lib
bin/testSandbox: obj/testSandbox.o lib/libcalog.a lib/liblua.a lib/libquickjs.a lib/libsquirrel.a lib/libmybasic.a lib/libberry.a lib/libs7.a lib/libwren.a lib/libjanet.a $(MRUBYLIB) $(TCLLIB) | bin
$(CC) $(LDFLAGS) -pthread -o $@ $^ $(LUALIBS) $(CXXLIB) -lm $(MRUBYLIBS) $(TCLLIBS)
# Destroy-hook phases: a registry holding script functions must drain while contexts are alive.
# JavaScript on purpose -- QuickJS aborts at JS_FreeRuntime if anything of its own is still live.
bin/testTeardown: obj/testTeardown.o obj/calogPubsub.o obj/calogExport.o obj/calogTimer.o lib/libcalog.a lib/liblua.a lib/libquickjs.a | bin
$(CC) $(LDFLAGS) -pthread -o $@ $^ $(LUALIBS) -lm
# calogAbortAll on every engine (what the runner's calogExit rides on), so it links all ten.
bin/testExit: obj/testExit.o lib/libcalog.a lib/liblua.a lib/libquickjs.a lib/libsquirrel.a lib/libmybasic.a lib/libberry.a lib/libs7.a lib/libwren.a lib/libjanet.a $(MRUBYLIB) $(TCLLIB) | bin
$(CC) $(LDFLAGS) -pthread -o $@ $^ $(LUALIBS) $(CXXLIB) -lm $(MRUBYLIBS) $(TCLLIBS)
bin/testTrace: obj/testTrace.o obj/calogExport.o lib/libcalog.a lib/liblua.a lib/libquickjs.a | bin
$(CC) $(LDFLAGS) -pthread -o $@ $^ $(LUALIBS)
@ -765,10 +774,10 @@ ssh-test: bin/testSsh
obj bin lib:
mkdir -p $@
test: all
test: all cross-lint
./bin/testBroker && ./bin/testLua && ./bin/testMyBasic && ./bin/testPolyglot && \
./bin/testActor && ./bin/testHooks && ./bin/testEngineLua && ./bin/testEngineMyBasic && ./bin/testSquirrel && ./bin/testEngineSquirrel && \
./bin/testJs && ./bin/testEngineJs && ./bin/testEngineBerry && ./bin/testEngineS7 && ./bin/testEngineWren && ./bin/testEngineMruby && ./bin/testEngineTcl && ./bin/testEngineJanet && ./bin/testLoad && ./bin/testDb && ./bin/testNet && ./bin/testTask && ./bin/testExport && ./bin/testJson && ./bin/testXml && ./bin/testTime && ./bin/testFs && ./bin/testCrypto && ./bin/testKv && ./bin/testTimer && ./bin/testPubsub && ./bin/testHttp && ./bin/testHttps && ./bin/testArchive && ./bin/testUtil && ./bin/testSandbox && ./bin/testTrace && ./bin/testHttpdLua
./bin/testJs && ./bin/testEngineJs && ./bin/testEngineBerry && ./bin/testEngineS7 && ./bin/testEngineWren && ./bin/testEngineMruby && ./bin/testEngineTcl && ./bin/testEngineJanet && ./bin/testLoad && ./bin/testDb && ./bin/testNet && ./bin/testTask && ./bin/testExport && ./bin/testJson && ./bin/testXml && ./bin/testTime && ./bin/testFs && ./bin/testCrypto && ./bin/testKv && ./bin/testTimer && ./bin/testPubsub && ./bin/testHttp && ./bin/testHttps && ./bin/testArchive && ./bin/testUtil && ./bin/testSandbox && ./bin/testExit && ./bin/testTeardown && ./bin/testTrace && ./bin/testHttpdLua
# ThreadSanitizer build of the actor core and the Lua engine path (cannot combine
# with ASan). Recompiled from source under TSan; the vendored Lua objects are
@ -928,10 +937,91 @@ fuzz: bin/fuzzJson bin/fuzzCsv bin/fuzzXml
fuzz-smoke: fuzz
@for f in Json Csv Xml; do echo "== fuzz-smoke $$f ($(FUZZSECS)s) =="; ./bin/fuzz$$f -max_total_time=$(FUZZSECS) -print_final_stats=1 || exit 1; done
# ---- cross-compilation: Windows PE, macOS Mach-O (x86_64 + arm64), musl-static ----------------
# NOT part of `make test`: these take minutes and need a zig toolchain, which a contributor building
# calog natively does not have to install -- `make`, `make test`, the tsan targets and `make fuzz`
# stay zig-free. The ONE exception is cross-lint below: zig-free, sub-second, and wired into `make
# test`, because the drift it catches is exactly how the cross builds went stale unnoticed for weeks
# (a source file or an ABI-visible define that never reached the cross scripts). What it cannot catch
# -- a target-specific runtime bug, a stale pinned dep -- needs a real cross build: `make cross`.
#
# Nothing here writes into obj/ bin/ lib/; all cross output lands under build/cross/ (gitignored), so
# a cross build never disturbs the sanitized native build in the same tree.
#
# zig lives outside the repo, resolved the way tools/cross*.sh do. ZIGBIN is recursive (`=`), so a
# plain `make` or `make test` never even runs the lookup.
ZIGDEFAULT = /home/scott/zig/current/zig
ZIG ?= $(firstword $(CALOG_ZIG) $(ZIGDEFAULT))
ZIGBIN = $(shell command -v $(ZIG) 2>/dev/null || { [ -x $(ZIG) ] && echo $(ZIG); })
CROSSENV = ZIG="$(ZIGBIN)" CALOG_ZIG="$(ZIGBIN)"
# Compile macros that are ABI-visible (object layout / integer width) rather than cosmetic: an
# adapter and its VM MUST agree on every one. MRB_USE_DEBUG_HOOK is the one that silently broke the
# Windows and macOS CLIs for weeks -- mrb_state grew a member the cross libmruby.a did not have.
CROSSFULL = tools/crossWinFull.sh tools/crossMacFull.sh
CROSSDEFS = _SQ64 SQUSEDOUBLE MB_DOUBLE_FLOAT MRB_USE_DEBUG_HOOK
# Zig-free, hermetic, sub-second. Every calog source the native build compiles must also be compiled
# by both full-CLI cross scripts, and every ABI define + engine selector must reach them. Text-level
# on purpose: it needs no toolchain, so `make test` can run it.
cross-lint:
@bad=0; \
for f in libs/*.c src/*.c src/*/*.c; do \
n=`basename $$f .c`; \
for s in $(CROSSFULL); do \
grep -q "$$n" $$s || { echo "cross-lint: $$f is compiled natively but not by $$s" >&2; bad=1; }; \
done; \
done; \
for d in $(CROSSDEFS) `grep -oE 'CALOG_WITH_[A-Z0-9_]+' Makefile | sort -u`; do \
for s in Makefile $(CROSSFULL); do \
grep -q -- "$$d" $$s || { echo "cross-lint: -D$$d is missing from $$s" >&2; bad=1; }; \
done; \
done; \
if [ $$bad -eq 0 ]; then echo "== cross-lint ok: cross scripts cover every calog source, ABI define and engine =="; \
else echo "== cross-lint FAILED: tools/cross*Full.sh drifted from the native build; fix them or run 'make cross' ==" >&2; exit 1; fi
# One place that decides whether a cross build can run. A hard error, never a silent skip: each
# target below was asked for explicitly, and a skip that reports success is how a broken cross build
# hides. (`make test` never reaches here -- it needs no zig.)
zig-check:
@test -n "$(ZIGBIN)" || { \
echo "error: zig not found (looked for '$(ZIG)')." >&2; \
echo " The cross-* targets need a zig toolchain: https://ziglang.org/download/" >&2; \
echo " Point make at a PERMANENT install: make ZIG=/path/to/zig $(MAKECMDGOALS)" >&2; \
echo " The native build, 'make test', tsan and fuzz do NOT need zig." >&2; \
exit 1; }
@echo "== zig: $(ZIGBIN) (`$(ZIGBIN) version`) =="
# The representative matrix: all ten engines for musl (built AND RUN here -- the only run-verified
# cross target), Windows PE and macOS Mach-O (both arches), plus calogNet and a real TLS run.
cross-smoke: zig-check
$(CROSSENV) bash ./tools/crossBuild.sh
# The full CLI per target. These consume the pinned heavy deps under build/cross/<target>/, which are
# gitignored -- build them first with the cross-deps-* targets if a fresh checkout lacks them.
cross-win: zig-check
$(CROSSENV) bash ./tools/crossWinFull.sh
cross-mac: zig-check
$(CROSSENV) bash ./tools/crossMacFull.sh
cross-deps-win: zig-check
$(CROSSENV) bash ./tools/crossDeps.sh win
cross-deps-mac: zig-check
$(CROSSENV) bash ./tools/crossDeps.sh mac-x64
$(CROSSENV) bash ./tools/crossDeps.sh mac-arm64
# musl is a partial target: the deps the cross ENGINE tests need (see tools/crossDeps.sh usage).
cross-deps-musl: zig-check
$(CROSSENV) bash ./tools/crossDeps.sh musl codecs openssl tcl mruby
# Everything that can be verified here, in the order that fails fastest.
cross: cross-lint cross-smoke cross-win cross-mac
clean:
rm -rf obj bin lib
-include $(wildcard obj/*.d)
-include $(wildcard obj/rel/*.d)
.PHONY: cross cross-lint cross-smoke cross-win cross-mac cross-deps-win cross-deps-mac cross-deps-musl zig-check
.PHONY: all test tsan tsansq tsanjs tsanmb tsanberry tsans7 tsanwren tsanmruby tsantcl tsanjanet tsanlibs tsanhttpd release fuzz fuzz-smoke clean

View file

@ -32,6 +32,22 @@ A socket `fd` is unsigned on Windows, so the code compares against `CALOG_INVALI
`fd < 0`). The abstraction is behavior-identical on POSIX (it maps to the same names), so the Linux
and macOS builds are unchanged by it.
### What the core assumes beyond C11
Sockets are the only surface that needed abstracting, but the core is not pure C11 either -- it
leans on three POSIX facilities that a new target must supply, none of them behind a wrapper:
| Facility | Used for | On Windows |
|---|---|---|
| `pthread_*` (threads, mutexes, condvars) | one thread per context, the message queues, the context registry | vendored **winpthreads** |
| `clock_gettime(CLOCK_MONOTONIC)` | `calogMonotonicMillis`: timer deadlines and the sandbox wall-clock budget | mingw-w64 |
| `nanosleep` | the host pump's park between drains, and the teardown's wait for an in-flight context close | mingw-w64 |
All three already resolve on the mingw-w64 / zig toolchain: `src/calogMain.c` calls `nanosleep`
unconditionally from its pump loop and is part of the Windows CLI that cross-built successfully, so
the teardown's use of it adds no new requirement. A target lacking any of them needs a shim in
`calogPlatform.h` rather than a change to the core.
## Build matrix
| Target | Command | Runtime dependencies | DNS |
@ -186,6 +202,96 @@ Everything lands under `build/cross/` (gitignored); the vendored sources and the
## Status
### Cross coverage as of 2026-07-24 (zig 0.16.0)
`tools/crossBuild.sh` now reports **39 ok, 0 failed**, and covers **all ten engines on all four
targets** rather than four engines on some:
| Target | What runs |
|---|---|
| musl static | all ten engine suites **RUN** (fully static, on this host), plus `testNet` and `testHttps` |
| Windows x64 PE | all ten engine suites + `testNet` build and link as PE32+ |
| macOS x86_64 + arm64 | all ten engine suites + `testNet` build and link as Mach-O |
Two of those are new kinds of evidence, not just more of the same:
- **TLS is exercised at runtime**, not merely linked. `testHttps` cross-builds fully static for musl
and runs here (6/6): an in-process RSA key + self-signed cert, a loopback TLS server, a rejected
untrusted cert, and the pinned-trust path -- all through the cross-built OpenSSL. It runs under a
strict runner (`mrunStrict`) because the ordinary one counts a nonzero exit as a pass, which would
have reported a broken handshake as success.
- **`tools/crossDeps.sh musl` is a full target**: every dep except `winpthreads` (Windows-only) and
`libarchive` (musl goes through `tools/crossArchive.sh`, which handles its iconv gotcha). Tcl and
mruby for musl are what let the last two engines into the matrix. Tcl needed two musl-specific
turns: configure picks the epoll notifier from the *host's* headers and its `tclEpollNotfy.c` wants
`<sys/queue.h>`, which musl does not ship (so the select notifier is selected instead), and with no
system zlib it must be pointed at the vendored one.
And the drift that started all of this is now caught mechanically: **`make cross-lint`** is a
zig-free, sub-second parity check wired into `make test`. It fails if a calog source the native build
compiles never reached both full-CLI cross scripts, or if an ABI-visible define (`MRB_USE_DEBUG_HOOK`
and friends) is missing from one of them -- exactly the class of drift that broke the Windows CLI for
weeks. `make cross` runs the real thing; `make cross-smoke`, `cross-win`, `cross-mac` and
`cross-deps-*` are the pieces. None of them are in `make test`, which stays hermetic and zig-free.
### Re-verified 2026-07-24 (zig 0.16.0)
The cross-builds were re-run after the teardown/`calogEnd` work. **The core changes port cleanly --
`src/context.c`, `src/value.c` and `src/broker.c` compile, link and run on every target** -- but the
run exposed three problems that all predate that work. All three are now fixed:
| Target | Result |
|---|---|
| musl static (built + run) | Lua, JavaScript, Squirrel, my-basic, **and `testNet` (10/10)** all pass |
| Windows x64 PE | `testEngineLua.exe` and `testNet.exe` **build**; the **full `calog.exe` builds** |
| macOS x86_64 + arm64 | `testEngineLua` and `testNet` **build** (both arches) |
`tools/crossBuild.sh` reports **11 ok, 0 failed**.
- **`testNet` on every target -- was failing, now fixed.** `libs/calogNet.c` has included
`<openssl/bio.h>` since the tcp transport gained TLS, and `tools/crossBuild.sh` passed no OpenSSL
flags: its `testNet` case predated that include. The test itself uses no TLS -- only the library it
links does -- so the fix is to link the per-target OpenSSL that `tools/crossDeps.sh` already
produces for win/mac, plus a new **`musl` target** in that script (OpenSSL only -- the full musl
CLI is `make static` on Alpine, not a cross build). `crossBuild.sh` now links it, and when a
target's OpenSSL has not been built it SKIPS with the exact command to produce it rather than
reporting a failure it cannot fix. The musl `testNet` is fully static and **runs 10/10 on the
build host**, matching the native run.
- **`testEngineMyBasic` on musl -- was 13 of 20 failing, now fixed (20/20).** The fork decided "this
symbol is a number" from `strtoll`/`strtod` alone reaching the string terminator. That is not
portable: **musl's `strtoll` advances `endptr` past leading whitespace and a sign even when no
conversion happens**, while glibc leaves it at the start. So on musl `+` and `-` (the operators)
and `\n` (the statement separator) each classified as the integer 0 -- every expression containing
an operator failed to parse, and every numeric assignment failed to run, both reported as
"Operator expected". Reproduced with a pure my-basic program containing no calog code at all
(`x = 1` -> `MB_FUNC_ERR` on musl, `MB_FUNC_OK` on glibc). Fixed in `vendor/ourbasic/ourBasic.c`
by rejecting a consumed span that is nothing but whitespace and sign; see that fork's CHANGELOG.
Worth remembering when porting: **a failed `strtol` does not leave `endptr` where you assume.**
- **The full CLI on Windows and macOS -- was broken, now fixed.** `tools/crossWinFull.sh` stopped at
`mrubyAdapter.c: no member named 'code_fetch_hook' in 'struct mrb_state'`. `src/mruby/build_config.rb`
defines `MRB_USE_DEBUG_HOOK` (the per-instruction hook the sandbox wall-clock budget needs) and the
Makefile passes the same macro to the adapter compile, but the cross build_config that
`tools/crossDeps.sh` generated defined only `MRB_INT64`, and neither `cross*Full.sh` passed the
macro -- so the cross `libmruby.a` had a different `mrb_state` layout than the adapter expected.
It had been broken since the sandbox-parity work landed (the previous `calog.exe` dated from just
before it), and failing loudly at compile time was the good outcome: the alternative was an ABI
mismatch. The macro is now in the generated cross config **and** in both `cross*Full.sh` adapter
compiles, and mruby was rebuilt for all three targets. **Rebuilt and re-verified:**
| Artifact | Result |
|---|---|
| `build/cross/win/calog.exe` | PE32+ x86-64, imports only Windows system DLLs |
| `build/cross/mac-x64/calog` | Mach-O 64-bit x86_64, PIE |
| `build/cross/mac-arm64/calog` | Mach-O 64-bit arm64, PIE |
Each links all ten engines and every library, and each carries this session's runtime changes
(`calogEnd` and the abort message are in the binaries). Still build-verified only -- running them
needs a Windows/wine or Mac host.
The bullets below record the state at the time each port was done; where they disagree with the
table above, the table is current.
- **Platform abstraction** (`calogPlatform.h`) and the net/ssh/http conversion: **done**, verified
behavior-identical on Linux (`make test` 30/30, socket + HTTP loopback smoke, ASan- and TSan-clean).
- **Linux release** target: **done and verified** -- `ldd bin/calog` shows only `libc`/`libm` + loader.

View file

@ -149,8 +149,11 @@ bin/calog config # no extension -> search config.lua / .js /
bin/calog producer.js consumer.lua # several files share one runtime (kv, pubsub, exports)
```
Scripts print with `calogPrint(...)` and end by calling `calogExit([code])` (calog is
event-driven, so a script asks to exit; `Ctrl-C` also works). [`API.md`](API.md) documents
Scripts print with `calogPrint(...)` and stop by calling `calogExit([code])`, which tears
everything down, or `calogEnd([code])`, which ends only the calling script and lets the others
run on (calog is event-driven, so a script asks to stop; `Ctrl-C` also works). Neither
returns -- the statement after it never runs, so a build script's `calogExit(1)` is final --
and once every script has ended, `calog` exits on its own. [`API.md`](API.md) documents
every native a script can call; [`examples/scripts/`](examples/scripts/) has runnable
examples across every engine and library, plus polyglot and multi-file demos.
@ -317,6 +320,10 @@ calogValueFree(&arg);
calogFnRelease(savedCb);
```
A reference you hold stays yours to release even after the script that made it is gone: as a
context stops it releases the engine handle behind every callable it owns, so `calogFnInvoke`
then reports `calogErrDeadE` instead of reaching into a torn-down interpreter.
It works the other way too: wrap one of your natives as a function value with
`calogFnFromNative` and return it from a native, and the script gets a callable it can
invoke (which routes back to your host thread). Every engine supports this; a script invokes
@ -406,6 +413,11 @@ void calogContextClose(CalogContextT *);
uint64_t calogContextId(const CalogContextT *);
uint64_t calogCurrentId(void); // 0 on the host thread
CalogT *calogCurrent(void);
// stopping scripts (what bin/calog's calogExit and calogEnd are built on)
int32_t calogAbortAll(CalogT *, CalogValueT *result); // return this from a native: every script stops
int32_t calogAbortCurrent(CalogValueT *result); // ...or only the calling one, which then retires
bool calogAborting(CalogT *); // true once the caller has been stopped
```
Statuses are `CalogStatusE` (`calogOkE`, `calogErrArgE`, `calogErrOomE`, ...); `calogOkE`
@ -426,6 +438,15 @@ make tsanmb # ThreadSanitizer: MY-BASIC
The ThreadSanitizer targets run under `setarch -R` (ASLR off) so TSan's shadow allocator
is happy on all kernels.
```sh
make cross # cross-build everything: musl (RUN), Windows PE, macOS Mach-O -- needs zig
make cross-smoke # just the matrix: all ten engines per target, plus net + a real TLS run on musl
make cross-lint # zig-free parity check; `make test` runs this one
```
Only `cross-lint` is part of `make test` -- the rest need a [zig](https://ziglang.org/download/)
toolchain, which building calog natively does not. See [`PORTING.md`](PORTING.md).
## Project layout
```

428
design.md
View file

@ -1384,3 +1384,431 @@ that bypassed the per-block charge). Two limitations are accepted and documented
Janet's `JANET_OUT_OF_MEMORY` is `exit(1)`). This is the allocation-granular bound already noted,
the same class as my-basic's statement-granular cap; the host-OOM crash is inherent to embedding
these VMs and predates the cap.
---
## 25. `calogExit` that does not return -- the runtime abort latch
`API.md` had always said of the runner's `calogExit([code])`: *"Does not return."* It did. The native
set two atomics (`gExitCode`, `gShutdown`) and handed control straight back to the script, which ran
on to the end of its chunk while the host pump loop was still one 0.5 ms tick away from noticing. Two
consequences, both silent:
- **Work after the exit still happened.** `if (failed) { calogExit(1) } ... deploy()` deployed.
- **A later exit overwrote an earlier one.** The last `calogExit` to run named the exit code, so a
script ending in `calogExit(0)` reported success no matter which failure had asked for `1` first.
That is a footgun for exactly the use calog is good at -- a build/CI script in whichever language
suits the job -- so the fix restores the documented semantics rather than documenting the behavior.
### The mechanism already existed
Every adapter turns a native that returns non-zero into an engine-level raise (`lua_error`,
`JS_Throw`, `sq_throwerror`, `be_raise`, `s7_error`, `wrenAbortFiber`, `mrb_exc_raise`, `TCL_ERROR`,
`janet_panic`, `MB_FUNC_ERR`). That is how the sandbox's wall-clock hook already stops a runaway
script (sec 24). So a native CAN unwind its caller: it only has to return an error and be sure
nothing reports it as a failure. `calogAbortAll` is that, made explicit:
```c
int32_t calogAbortAll(CalogT *calog, CalogValueT *result); // latch the runtime, return calogErrAbortE
bool calogAborting(CalogT *calog); // true once the caller has been stopped
```
`nativeCalogExit` records the code and returns `calogAbortAll(...)`. The engine raises it, the script
unwinds out of its chunk at the call site, and the statement after `calogExit` never runs. The native
must be registered with `calogRegisterInline` for this to work at all -- an abort raised on the host
thread could not unwind a script on another thread.
### One latch, runtime-wide
The obvious design -- abort only the calling context -- needs cross-context propagation, because the
error status is flattened into a string every time it crosses an engine boundary (script A calls
script B's export, B calls `calogExit`; A only ever sees B's message). A single `_Atomic bool` on
`CalogT`, checked at the two dispatch choke points every script call already funnels through, is both
simpler and stronger:
| choke point | what it refuses once latched |
|---|---|
| `calogCall` (broker.c) | every native, from every context and every engine -- including `calogExit` itself |
| `calogFnInvoke` (value.c) | every script function value: a queued timer or pubsub callback never starts a script body |
So the abort needs no propagation: whichever context is next to touch C is stopped there, and a
script that *catches* the unwind (`pcall`, `try`, `catch`) cannot call a native or invoke a callable
afterwards -- it can only spin until the host joins it. The latch is per-runtime, so independent
`CalogT`s in one process are unaffected, and it is one-way: a latched runtime is a tearing-down
runtime.
### An aborted script did not fail
The unwind reaches each adapter's run function as an ordinary engine error, which would print a
diagnostic and report a script error -- two spurious lines for a perfectly normal `calogExit(3)`. So
every adapter's RUN-failure branch (never its compile branch: a syntax error cannot be an abort) does
its usual cleanup and returns `calogOkE` when `calogAborting(context->broker)`. Returning ok is what
keeps `contextDispatchEval` from posting an error, so the error handler never sees it and the runner
never marks that context failed.
**Janet is the one engine that needed more.** Alone among the ten it reports the failure itself, from
inside `janet_dobytes` (error line plus stack trace, through `janet_eprintf`), before the adapter can
decide anything. `janet_eprintf` honors the `err` dynamic binding, so the adapter binds a capture
buffer for the duration of the run and relays it to stderr verbatim only for a real failure. The
binding has to go in the TOP dyn table -- `janet_dobytes` prints after `janet_continue` has returned,
when no fiber is current -- and Janet does not mark that table, so the buffer and the `:err` keyword
are `janet_gcroot`ed by hand for the run. A script's own `(eprint ...)` is untouched: inside a fiber
the lookup finds the context env's bindings, not the top table.
### Exit-code precedence in the runner
`gExitRequested` latches the first request of any kind -- a `calogExit`, a signal, or a script
erroring out -- and everything after it is ignored. First-writer-wins is the only rule under which a
failure cannot be masked: a later `calogExit(0)`, from the same script or a sibling, no longer clears
an error that already happened, and an error thrown up during teardown no longer overwrites the code
a script deliberately asked for. A second `calogExit` never even reaches the native (the abort latch
refuses it), and a failed launch reports `1` without ending the run for the scripts that did launch.
"First" means first *observed*, and a script error is observed when it reaches the host thread. So in
a multi-script run, a sibling erroring at the same moment one script calls `calogExit(0)` is a race
-- as everything else between concurrently running scripts is. Within one script there is no race at
all, which is the case that matters: its own `calogExit(1)` unwinds it, so no later line of that
script, `calogExit(0)` included, can ever run.
### Teardown order stays the host's
`calogAbortAll` deliberately does not close the calling context. Teardown order is the host's --
`calogDestroy` releases the cross-context reference holders while every context is still alive, and
only then joins them (that is why `calogTimer` registers its shutdown `calogDestroyBeforeContextsE`).
A context that retired itself from inside the abort would destroy its own interpreter first,
stranding the callables those registries still hold: harmless for a VM that frees everything on
close, fatal for QuickJS, which asserts it owns no live objects at `JS_FreeRuntime`. Stopping the
scripts is the latch's job and needs no context to close.
### Accepted limitations
- **A signal does not abort scripts.** `SIGINT`/`SIGTERM` still only request shutdown: a signal
handler must not run the actor layer, and an interrupt is not a script asking to stop. A script
finishes its current chunk while the host tears down, exactly as before.
- **A script that deliberately catches the unwind is not preempted mid-computation** -- the same
cooperative-model limit as the sandbox (sec 24), and the same answer: it is stopped at its next
native call, and pure spinning is ended by the host's teardown.
- **Four engines cannot tell a compile failure from a run failure**, so a chunk that begins
compiling *after* the latch has its syntax error swallowed along with the abort: QuickJS
(`JS_Eval` compiles and runs in one call), Tcl (`Tcl_EvalEx`), mruby (`mrb_load_string` reports
both through `mrb->exc`), and s7 (its catch wrapper flags read and run errors alike). The other
six report the two separately and only quiet the run failure. The window is a teardown-only one --
an eval already queued when the abort landed -- so the cost is a lost diagnostic for a script
that was never going to run anyway.
### Test
`tests/testExit.c` runs, on all ten engines, `mark(); stopAll(); <loop forever>`. The loop is the
evidence: native calls cannot prove the point (the latch refuses them whether or not the script
unwound), but a script that kept running would pin its context thread, so the check is that the
context's thread has exited within the pump budget -- the test's own native adds a
`calogCurrentRetire`, serviced only once an eval returns, to make "the eval returned" observable.
The same run asserts the abort reached no error
handler, that the runtime stays latched, and that a later `calogCall` is refused with
`calogErrAbortE` without reaching the native. A final case proves the latch is per-runtime.
---
## 26. Reclaiming a context's callables -- and `calogEnd`
A `CalogFnT` is a handle to a function living inside one VM. Section 25 made `calogExit` unwind
scripts; this section fixes the class of bug that made the *next* question -- "how does one script
end itself?" -- unsafe to answer.
### The bug: a handle outliving its VM
`threadMain` destroys a context's interpreter as that context's thread exits. Anything still holding
a `CalogFnT` owned by that context is then holding a handle into a VM that no longer exists, and the
engine-side release never runs: `calogFnFinalize` sees the owner is gone and frees only the shell.
Most VMs hide it -- they free everything on close -- but QuickJS asserts it owns no live objects and
aborts the process:
```
calog: vendor/quickjs/quickjs.c:2682: JS_FreeRuntime: Assertion `list_empty(&rt->gc_obj_list)' failed.
```
Three separate holders reproduce it, all deterministically:
| holder | reproducer (JavaScript, because QuickJS is the VM that checks) |
|---|---|
| a library registry | `psSubscribe('t', function(){});` then exit, error, or `calogEnd` |
| another engine | `calogCall('keep', function(){})` into a Lua script that stores it, then die |
| an in-flight invoke | a delivery holding the last reference across the owner's teardown |
The first instinct -- move the pubsub and export destroy hooks from `calogDestroyAfterContextsE` to
`calogDestroyBeforeContextsE`, as `calogTimer` already does -- fixes only the whole-runtime teardown
path, and only for the last runtime in the process. It does nothing for a context that dies while the
runtime lives on, which is the common case: a script that errors after subscribing, `taskExit`, and
now `calogEnd`. It also cannot reach a value another VM is holding, where no registry is involved.
### The fix: the owner reclaims, on its own thread
The rule that actually holds is **a context releases every engine handle it owns before its
interpreter goes away**, and only the owner's own thread can do that. So each context now lists the
callables it creates (`calogContextTrackFn` / `calogContextUntrackFn`, guarded by that context's
queue mutex, locked in the runtime's established `ctxMutex` -> `queueMutex` order), and `threadMain`
sweeps the list after the context hooks and before `interpDead` / `destroyInterpreter`:
```c
contextReclaimCallables(context); // per callable: mark dead, run the engine release, clear the hook
```
`calogFnReclaim` marks the callable dead and runs the engine release while the VM is still alive,
then clears the hook -- so every later holder, whatever thread it is on and whenever it gets there,
finalizes an empty shell. An invoke of a reclaimed callable fails with `calogErrDeadE`, the same
answer the actor layer already gave for a context that is gone (which is what the timer library
cancels a timer on, and what `calogFnMarkDead` -- until now dead code with no callers -- was for).
The sweep takes the whole list under the lock before touching anything, and retains each entry across
the pass: an engine release can cascade (a VM finalizer dropping another of this context's callables)
straight back into `calogContextUntrackFn`, and must not find a list being walked or free an entry
the sweep has not reached.
This makes the destroy-phase question moot -- no phase changed -- because by the time any registry
releases its reference, the handle it names is already gone. Two smaller repairs came with it:
`psSubscribe` and `calogExport` now refuse to add to a registry that has already been drained (the
guard `timerSchedule` already had: `gInitMutex` held across the whole insert, in that lock order, so
it cannot straddle a shutdown), and `calogActorShutdown` destroys `ctxMutex` after the host-queue
drain rather than before, since a callable finalized by that drain resolves its owner under it.
### Closing the registry before walking it
The same audit found a second teardown defect, unrelated to callables. `calogActorShutdown` walked
`calog->ctxSlots` and `calog->ctxCount` **without holding `ctxMutex`** -- and that walk is exactly
when scripts are still running and still free to call `taskSpawn` / `taskLoad`, each of which opens a
context and can `realloc` the slot array out from under the walk. A context registered after the walk
had passed its slot was then freed by the third loop with its thread still running, and that thread
outlived the slot array it was registered in.
`calogContextOpen` made it worse from the other side: it filled the slot *before* `pthread_create`
and published `started` *after*, so even a locked read could catch a context whose thread was already
running but whose `started` flag said otherwise -- skipped by both the shutdown-request and the join
loops.
Both halves close with one idea -- **latch the registry, then walk it**:
- `calogActorShutdown` sets `tearingDown` under `ctxMutex` as its first act and reads `ctxCount` in
the same critical section. From that moment `calogContextOpen` refuses (a `taskSpawn` racing the
teardown simply fails), so the slots it is about to walk are the complete and final set and the
array can no longer move.
- Every slot read in the walks takes `ctxMutex` (`contextAtIndex`), because a script thread can
still be unlinking a context of its own.
- `pthread_create` and `started = true` moved inside the registration critical section, so a locked
read sees either an empty slot or a slot whose thread exists. Nothing waits on the new thread while
the lock is held, so starting it there cannot deadlock -- the new thread merely waits out the
handful of instructions to the unlock if its own first act needs the registry.
`tests/testHooks.c` pins it deterministically: a per-context shutdown hook runs on the context's own
thread *while* `calogDestroy` is walking the registry, which is precisely the window, and opening a
context from inside that hook must be refused. `tests/testTask.c` adds the stress companion, a spawn
loop racing `calogDestroy` on its own runtime.
### One closer per context
The latch fixes the registry, not the contexts in it. `taskClose` is an inline native, so a script
thread can call `calogContextClose` on a task at the same moment the teardown is stopping that very
context -- and `pthread_join` from two threads is undefined, quite apart from one of them freeing the
context the other is still inside. A registry lock cannot express that: the join must not be held
under a lock the joined thread may itself need.
What it needs is a claim. Each context carries a `closing` flag guarded by `ctxMutex`, and
`calogContextClose` takes it or returns:
```c
if (broker->tearingDown || context->closing) { unlock; return; } /* someone else owns this one */
context->closing = true;
```
`tearingDown` is the teardown's claim on every context at once, so once the latch is up no new close
can start. The only case left is a close that was *already* running when the latch went up, and
`calogActorShutdown` waits for exactly those -- polling the slots it is about to walk until none is
flagged `closing`, since that closer unlinks the slot when it finishes. The latch guarantees the set
only shrinks, so the wait terminates; in practice it never runs a single iteration.
A script that calls `taskClose` during teardown simply gets a no-op: the context it named is stopped
and freed moments later by the teardown that already owns it.
`tests/testTask.c` races a loop of `taskSpawn` + `taskClose` against `calogDestroy` on its own
runtime -- clean under ASan across repeated runs, and under ThreadSanitizer with the Lua and
JavaScript engines linked.
### `calogEnd([code])` -- ending one script
With reclamation in place a script can safely end itself, which is what the runner's new `calogEnd`
does. It is `calogAbortCurrent`: the same unwind as `calogAbortAll`, scoped to one context. The
calling script stops at the call, that context alone is latched (so a caught unwind cannot call
another native), and the context retires -- its thread ends, and the runner's pump loop closes it,
drops the live count, and exits once the last launched script is gone. Other scripts are untouched.
No engine adapter changed for any of this. The ten of them ask one question -- `calogAborting`
(sec 25) -- and that question was widened rather than duplicated: it now answers *has the caller been
stopped*, by the runtime latch or by its own context. A second query would have meant a second edit
to ten files and two ways for them to disagree.
An optional `code` names the process's status on the same first-writer-wins rule as `calogExit`;
omitting it claims nothing. The three ways a script can stop now read as one set:
| | ends | process status |
|---|---|---|
| `calogEnd([code])` | this script | `code` if given, else unclaimed |
| `error(...)` | this script | 1 (the run did not fully succeed) |
| `calogExit([code])` | everything | `code` (default 0) |
`taskExit()` is deliberately left alone: it stays deferred, as documented, and remains the
task-scoped way for a spawned task to retire itself.
### Test
`tests/testTeardown.c` uses JavaScript throughout, because on any other engine a stranded handle is
invisible -- there, surviving the teardown IS the assertion. It covers a subscriber, an export and a
timer callback left registered at `calogDestroy`; each of those whose script instead errors out
first; a script that ends itself with the `calogEnd` primitive; a closure handed to a Lua script
whose owner then dies (invoking it afterwards must fail cleanly, not reach into a destroyed VM); and
the drained-registry guards.
---
## 27. What the cross-builds caught -- three portability regressions
Sections 25 and 26 changed the actor core, so the Windows/macOS/musl cross-builds were re-run
afterwards. The core changes ported cleanly. Everything else the run turned up predated them, and had
been sitting there unnoticed for one structural reason worth stating plainly: **the cross-builds are
not part of `make test`.** Nothing else in the project can drift silently for weeks; these can, and
did. The habit that catches it is to run `tools/crossBuild.sh` after touching the core or a vendored
dependency -- it is a couple of minutes, and it is what surfaced all three of these.
### An ABI-shaped define that only half the build knew about
`tools/crossWinFull.sh` stopped at `mrubyAdapter.c: no member named 'code_fetch_hook' in
'struct mrb_state'`. `MRB_USE_DEBUG_HOOK` enables the per-instruction hook the sandbox wall-clock
budget needs (sec 24) -- and it **changes the layout of `mrb_state`**. `src/mruby/build_config.rb`
sets it for the library and the Makefile passes it to the adapter compile, so the two agree natively.
The cross config generated by `tools/crossDeps.sh` set only `MRB_INT64`, and neither `cross*Full.sh`
passed the macro, so the cross `libmruby.a` and the adapter disagreed about the struct.
It had been broken since sec 24 landed. Failing loudly at compile time was the good outcome: had the
member merely moved rather than vanished, this would have been a silent ABI mismatch in a shipped
binary. The macro now lives in the generated cross config **and** in both full-CLI adapter compiles.
The general rule it earns: *a define that changes a vendored library's layout has to be set every
place that compiles against that library, and the cross path is a place.*
### A libc that returns `endptr` somewhere else
`testEngineMyBasic` failed 13 of 20 checks on musl while passing under native gcc, native clang
`-O2`, and zig targeting glibc -- so the variable was musl, not the compiler. my-basic classified a
symbol as a number by asking whether `strtoll`/`strtod` had reached the string terminator:
```
strtoll("\n") strtoll("+")
glibc: consumed=0, endptr at start consumed=0, endptr at start
musl: consumed=1, endptr at NUL consumed=1, endptr at NUL
```
**musl advances `endptr` past leading whitespace and a sign even when no conversion happens.** So on
musl `+`, `-` (the operators) and `\n` (the statement separator) each classified as the integer 0:
every expression containing an operator failed to parse, and every numeric assignment failed to run,
both surfacing as "Operator expected". It reproduces in a pure my-basic program with no calog code at
all (`x = 1` -> `MB_FUNC_ERR` on musl, `MB_FUNC_OK` on glibc), which is how it was pinned down. The
fork now rejects a consumed span that is nothing but whitespace and sign; `vendor/ourbasic/CHANGELOG`
has the detail. The lesson generalizes past my-basic: *a failed `strtol` does not leave `endptr`
where you assume -- test what was consumed, not just where it stopped.*
### A dependency that grew under a script that had stopped looking
`testNet` failed to build on all four targets: `libs/calogNet.c` has included `<openssl/bio.h>` since
the tcp transport gained TLS, and `tools/crossBuild.sh` passed no OpenSSL flags. The test itself uses
no TLS -- only the library it links does -- so nothing was wrong with the coverage, just with the
link line. It now links the per-target OpenSSL that `tools/crossDeps.sh` already produced for
Windows and macOS, and that script gained a **musl** target for OpenSSL alone (the full musl CLI is
`make static` on Alpine, where the toolchain is already musl -- not a cross build). When a target's
OpenSSL has not been built, the case SKIPS with the command that produces it, and the summary counts
skips separately: a skip must never be able to read as a pass.
### Where that leaves the ports
| | Before | After |
|---|---|---|
| `tools/crossBuild.sh` | 7 ok, 4 failed | **11 ok, 0 failed** |
| musl `testNet` | did not build | **runs 10/10**, fully static, matching the native run |
| musl `testEngineMyBasic` | 13 of 20 failing | **20/20** |
| Full CLI: Windows PE, macOS x86_64 + arm64 | Windows did not build | **all three build**, carrying sec 25/26 |
The standing gap is unchanged and deliberate: Windows and macOS are **build**-verified only. Running
those binaries needs a Windows/wine or Mac host, so "it links" is the strongest claim the evidence
supports, and PORTING.md says exactly that rather than rounding it up.
---
## 28. Three latent edges, closed
None of these ever fired in a test. They were found by reading the code around sections 26 and 27 --
two in the actor core, one in the my-basic fork -- and each is the kind that stays quiet until the
day it does not.
### The release that could run on the wrong thread
`actorReleaseCallable` marshals a callable's finalize to its owner's thread, because the adapter's
release is an interpreter op (`luaL_unref`, `JS_FreeValue`, ...) and only the owner may touch that
interpreter. Its fallback did not hold that line:
```c
if (contextPostRelease(runtime, owner, callable) != calogOkE) {
calogFnFinalize(callable); /* on THIS thread -- not the owner's */
}
```
`contextPostRelease` fails for two different reasons, and they are not equally harmless. If the owner
is **gone**, `calogFnFinalize` takes its "owner gone" branch and only frees memory -- fine. But it
also fails when the `calloc` for the message fails while the owner is **alive**, and then
`calogContextRegistered` returns true and the engine op runs from the wrong thread. That is memory
corruption inside a running VM, reached under memory pressure -- the moment a program is least able to
cope with it.
`calogFnFinalizeForeign` is the fallback now: untrack, free the adapter's block, free the shell, and
deliberately **leak the handle inside the interpreter**. A leak on an OOM path beats corrupting a VM
that is still running. The "owner gone" case is unchanged -- it already did exactly this.
One subtlety, caught in review before this shipped: it frees `userData` on **exactly** the condition
`calogFnFinalize` does -- only when a release hook exists. The hook is what makes `userData` calog's
to free (an adapter's single heap block, per the `CalogReleaseFnT` contract). A host-owned callable
from `calogFnFromNative` has no hook and a `userData` the **embedder** owns; freeing that would
corrupt their heap, which is worse than the corruption this function exists to prevent. And the leak
is worth stating honestly: the handle is pinned until that interpreter is destroyed, which for a
long-lived context is not "a moment".
### The window where a queue accepted work nobody would serve
`serveLoop` returns when `messageDequeue` finds the queue empty and `shuttingDown` set. `interpDead`
-- which is what stops `registryResolveLocked` handing the context out -- is set later, after the
per-context hooks and the callable reclaim. Between those two points the context still resolved, so a
message enqueued there was **accepted and never served**: a fire-and-forget eval silently dropped, and
a blocking call left its sender waiting on a reply that could not come (the reply-box path waits on a
condvar with no timeout, so `calogDestroy`'s join on that sender never returns).
Moving `interpDead` earlier would have been the wrong fix -- the reclaim depends on its ordering. The
fix is to make *closing the queue* atomic with the decision to stop serving, which is a different
fact from *the interpreter is gone*:
- `queueClosed` on the context, guarded by that context's `queueMutex`;
- `messageDequeue` sets it in the **same critical section** where it decides to return NULL;
- `enqueueRaw` refuses while holding that mutex, and `contextEnqueue` reports `calogErrDeadE`.
Every caller already handled that status, so nothing downstream changed. `tests/testHooks.c` pins the
window deterministically: a per-context shutdown hook runs inside it (after `serveLoop`, before
`interpDead`), and queueing work from there must be refused. That check fails against the pre-fix code
and passes after -- the test has teeth, which for a window this narrow is the only evidence worth
having.
### An index that could go negative
`_get_priority_index` (my-basic) linearly searches a table of operator function pointers and returns
`-1` when it finds none. `_get_priority` then does `_PRECEDE_TABLE[idx1][idx2]`, and the assert
guarding it checks only the **upper** bound. calog defines no `NDEBUG`, so today the `mb_assert`
fires; an embedder who builds with `NDEBUG` gets a silent out-of-bounds read instead.
The table already has a marker for "these two cannot operate together" -- a space -- and the
evaluator turns it into a clean `SE_RN_FAILED_TO_OPERATE` script error. So a negative index now
returns `' '`: an unknown operator becomes a reported script error rather than a read off the front of
a static array. (It has never fired -- an instrumented build confirmed the lookup always resolves --
which is exactly why it was worth closing while it was still theoretical.)

View file

@ -21,7 +21,11 @@ Conventions every example follows:
engine's own `print`/keyword; on Wren it is `Calog.call("calogPrint", [...])`).
- **`calogExit([code])`** ends the run. calog is event-driven -- a script's top level
finishing does not exit the process (it may still have timers/subscriptions live), so a
script asks to exit explicitly. `Ctrl-C` also tears things down cleanly.
script asks to exit explicitly. `Ctrl-C` also tears things down cleanly. `calogExit` does
not return: the statement after it never runs, and the first code asked for is the one the
process reports.
- **`calogEnd([code])`** ends just the calling script, leaving the others running; it does not
return either, and its context is reaped. Once every script has ended, `calog` exits on its own.
- Extensions map to engines: `.lua .js .nut .bas .be .scm .wren`.
## `languages/` -- one guided tour per engine
@ -79,7 +83,8 @@ Conventions every example follows:
## `multifile/` -- multiple script files in one run
Files listed on the `calog` command line run **concurrently in one process** and share the
same runtime (kv store, pubsub bus, exports). One `calogExit()` from any file ends the run.
same runtime (kv store, pubsub bus, exports). One `calogExit()` from any file ends the run --
it unwinds its own script immediately and stops the others at their next native call.
```sh
bin/calog examples/scripts/multifile/producer.js examples/scripts/multifile/consumer.lua

View file

@ -168,6 +168,16 @@ static int32_t exportPublish(CalogValueT *args, int32_t argCount, CalogValueT *r
}
name = args[0].as.s.bytes;
nameLen = args[0].as.s.length;
// Hold gInitMutex across the whole publish so it can never straddle a calogExportShutdown
// (calogRegistryRelease holds the same lock over exportFreeAll): either the map is live and this
// entry will be freed with it, or the map is already gone and this fails outright. Adding to a
// drained map would strand the entry -- nothing frees it a second time. Lock order is gInitMutex
// then gMapMutex, matching shutdown; the reverse would deadlock.
pthread_mutex_lock(&gInitMutex);
if (gRefCount <= 0) {
pthread_mutex_unlock(&gInitMutex);
return calogFail(result, calogErrDeadE, "calogExport: the export registry has been shut down");
}
pthread_mutex_lock(&gMapMutex);
index = exportFindLocked(name, nameLen);
if (index >= 0) {
@ -176,6 +186,7 @@ static int32_t exportPublish(CalogValueT *args, int32_t argCount, CalogValueT *r
calogFnRetain(args[1].as.fn);
gEntries[index].fn = args[1].as.fn;
pthread_mutex_unlock(&gMapMutex);
pthread_mutex_unlock(&gInitMutex);
return calogOkE;
}
buffer = gEntries;
@ -183,6 +194,7 @@ static int32_t exportPublish(CalogValueT *args, int32_t argCount, CalogValueT *r
status = calogGrow(&buffer, &capacity, (int64_t)gCount + 1, sizeof(ExportEntryT));
if (status != calogOkE) {
pthread_mutex_unlock(&gMapMutex);
pthread_mutex_unlock(&gInitMutex);
return calogFail(result, status, "calogExport: out of memory");
}
gEntries = (ExportEntryT *)buffer;
@ -190,6 +202,7 @@ static int32_t exportPublish(CalogValueT *args, int32_t argCount, CalogValueT *r
nameCopy = (char *)malloc((size_t)nameLen + 1);
if (nameCopy == NULL) {
pthread_mutex_unlock(&gMapMutex);
pthread_mutex_unlock(&gInitMutex);
return calogFail(result, calogErrOomE, "calogExport: out of memory");
}
if (nameLen > 0) {
@ -202,6 +215,7 @@ static int32_t exportPublish(CalogValueT *args, int32_t argCount, CalogValueT *r
gEntries[gCount].fn = args[1].as.fn;
gCount++;
pthread_mutex_unlock(&gMapMutex);
pthread_mutex_unlock(&gInitMutex);
return calogOkE;
}

View file

@ -172,9 +172,20 @@ static int32_t pubsubSubscribe(CalogValueT *args, int32_t argCount, CalogValueT
if (argCount != 2 || args[0].type != calogStringE || args[1].type != calogFnE) {
return calogFail(result, calogErrArgE, "psSubscribe expects (topic, function)");
}
// Hold gInitMutex across the whole subscribe so it can never straddle a calogPubsubShutdown
// (calogRegistryRelease holds the same lock over pubsubFreeAll): either the registry is live and
// this entry will be freed with it, or the registry is already gone and this fails outright.
// Adding to a drained registry would strand the entry -- nothing frees it a second time. Lock
// order is gInitMutex then gListMutex, matching shutdown; the reverse would deadlock.
pthread_mutex_lock(&gInitMutex);
if (gRefCount <= 0) {
pthread_mutex_unlock(&gInitMutex);
return calogFail(result, calogErrDeadE, "psSubscribe: pubsub has been shut down");
}
topicLen = args[0].as.s.length;
topicCopy = (char *)malloc((size_t)topicLen + 1);
if (topicCopy == NULL) {
pthread_mutex_unlock(&gInitMutex);
return calogFail(result, calogErrOomE, "psSubscribe: out of memory");
}
memcpy(topicCopy, args[0].as.s.bytes, (size_t)topicLen);
@ -185,6 +196,7 @@ static int32_t pubsubSubscribe(CalogValueT *args, int32_t argCount, CalogValueT
status = calogGrow(&buffer, &capacity, (int64_t)gCount + 1, sizeof(SubEntryT));
if (status != calogOkE) {
pthread_mutex_unlock(&gListMutex);
pthread_mutex_unlock(&gInitMutex);
free(topicCopy);
return calogFail(result, status, "psSubscribe: out of memory");
}
@ -199,6 +211,7 @@ static int32_t pubsubSubscribe(CalogValueT *args, int32_t argCount, CalogValueT
gEntries[gCount].callback = args[1].as.fn;
gCount++;
pthread_mutex_unlock(&gListMutex);
pthread_mutex_unlock(&gInitMutex);
calogValueInt(result, id);
return calogOkE;
}

View file

@ -493,18 +493,27 @@ static int32_t berryFromValue(CalogBerryT *context, const CalogValueT *value, in
int32_t calogBerryRun(CalogBerryT *context, const char *source) {
bvm *vm;
int base;
int code;
bvm *vm;
bool compiled;
int base;
int code;
vm = context->vm;
base = be_top(vm);
code = be_loadstring(vm, source);
if (code == BE_OK) {
vm = context->vm;
base = be_top(vm);
code = be_loadstring(vm, source);
compiled = (code == BE_OK);
if (compiled) {
code = be_pcall(vm, 0);
}
if (code != BE_OK) {
const char *message;
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, and the
// runtime is already tearing down, so report nothing. Only a script that actually RAN can
// have been aborted, so a syntax error still gets its diagnostic even mid-teardown.
if (compiled && calogAborting(context->broker)) {
be_pop(vm, be_top(vm) - base);
return calogOkE;
}
message = be_tostring(vm, -1);
fprintf(stderr, "berry error: %s\n", message != NULL ? message : "(unknown)");
be_pop(vm, be_top(vm) - base);

View file

@ -35,6 +35,13 @@ int32_t calogCall(CalogT *broker, const char *name, CalogValueT *args, int32_t a
CalogEntryT *entry;
calogValueNil(result);
// The caller has been stopped -- the runtime torn down (calogAbortAll) or this one script ended
// (calogAbortCurrent). Refuse every native here, at the one dispatch point every engine goes
// through, so a script that caught the unwinding error is stopped again at its very next call
// instead of running on past it.
if (calogAborting(broker)) {
return calogFail(result, calogErrAbortE, CALOG_ABORT_MESSAGE);
}
entry = calogLookup(broker, name);
if (entry == NULL) {
return calogFail(result, calogErrNotFoundE, "no such function");

View file

@ -44,7 +44,10 @@ typedef enum CalogStatusE {
calogErrArgE = 5,
calogErrRangeE = 6,
calogErrUnsupportedE = 7,
calogErrDeadE = 8
calogErrDeadE = 8,
// The runtime has been latched aborting (calogAbortAll): the call was refused because no script
// may run any more. Every engine raises it as an error, which unwinds the script out of its chunk.
calogErrAbortE = 9
} CalogStatusE;
typedef enum CalogTypeE {
@ -258,6 +261,26 @@ CalogT *calogCurrent(void); // runtime of the calling context (NULL i
void calogCurrentRetire(void); // ask the calling context to retire itself (deferred; see calogTask)
bool calogCurrentShuttingDown(void); // true once the calling context has been asked to close
// ---- stopping every script (the runner's calogExit) ----
// Latch this runtime ABORTING: no context may run another line of script. Call it from inside a
// native and RETURN ITS VALUE -- the calling engine raises that error, so the script unwinds right at
// the call instead of running on, which is what lets a native "not return". Every later native call
// from any context of this runtime fails the same way, so a script that catches the unwind cannot
// keep working, and an aborted script is NOT reported to the error handler (it did not fail). No
// context is closed here -- teardown stays the host's, in its own order. One-way and idempotent:
// a latched runtime never runs script code again, so latch it only when tearing the runtime down. The
// host decides what happens next -- bin/calog exits the process with the code the script asked for.
int32_t calogAbortAll(CalogT *calog, CalogValueT *result);
// Stop only the CALLING script, leaving the runtime and every other script running (the runner's
// calogEnd). Used the same way -- call it from a native and return its value, and the script unwinds
// at the call site. The context also retires itself, so its thread ends and the host can reap it;
// like an aborted script it is not reported as a failure. Fails if there is no calling script.
int32_t calogAbortCurrent(CalogValueT *result);
// True when the caller must stop running script code -- this runtime was latched by calogAbortAll,
// or the calling context ended itself. Engine adapters use it to tell a script that was stopped
// (report nothing) from one that genuinely failed.
bool calogAborting(CalogT *calog);
extern const CalogEngineT calogLuaEngine;
extern const CalogEngineT calogJsEngine;
extern const CalogEngineT calogSquirrelEngine;

View file

@ -9,6 +9,12 @@
#include "calog.h"
#include <pthread.h>
#include <stdatomic.h>
// What an aborted script sees: the error every engine raises to unwind a script out of its chunk once
// the runtime is latched aborting (calogAbortAll), and the failure every later native call returns.
// One source of truth for both, so the two never drift.
#define CALOG_ABORT_MESSAGE "calog: the runtime is shutting down"
#ifdef _WIN32
// memmem is a GNU/BSD extension that mingw-w64's libc does not provide, so calog's own scanners
@ -110,6 +116,10 @@ struct CalogT {
int64_t *ctxFree; // recycled slot indices
int64_t ctxFreeCount;
int64_t ctxFreeCap;
// Set by calogActorShutdown (under ctxMutex) before it walks the registry: calogContextOpen
// refuses from then on, so a script cannot register a context the teardown would miss -- and
// ctxSlots/ctxCount stop moving, since only an open ever grows them.
bool tearingDown;
// actor layer (context.c): installed by calogActorInit, all NULL on a bare broker
CalogContextT *hostContext; // id 0, no thread; driven by calogPump
pthread_t hostThread; // thread that ran calogActorInit (this runtime's host)
@ -118,6 +128,9 @@ struct CalogT {
CalogReleaseHookT releaseHook;
CalogErrorFnT errorHandler;
void *errorUserData;
// calogAbortAll latch: every context stops running script code (each native call is refused, so
// the engine unwinds it). Read on every dispatch from every thread, hence atomic. One-way.
_Atomic bool aborting;
// engines available to calogContextLoad (calogRegisterEngine), search-priority order
const CalogEngineT **engines;
int64_t engineCount;
@ -144,6 +157,16 @@ void calogForEach(CalogT *calog, void (*visit)(const CalogEntryT *entry,
// ---- callable lifecycle (driven by the engine adapters) ----
int32_t calogFnCreate(CalogFnT **out, CalogT *runtime, CalogNativeFnT fn, void *userData, CalogReleaseFnT release, uint64_t ownerCtxId);
void calogFnFinalize(CalogFnT *fn);
// As calogFnFinalize, but skips the engine release: for a caller that is NOT on the owner's thread
// and could not marshal the release there. Frees the adapter's block and the shell, leaving the
// handle inside the interpreter to die with it -- a leak, deliberately, instead of an interpreter op
// on the wrong thread.
void calogFnFinalizeForeign(CalogFnT *fn);
// The owning context is about to destroy its interpreter: run the engine's release NOW, while that
// interpreter is still alive, and neutralize the callable so a later finalize -- from a library
// registry, from another engine still holding this value, from an invoke in flight -- only frees the
// shell. Runs on the owner's own thread (contextReclaimCallables). See design.md sec 26.
void calogFnReclaim(CalogFnT *fn);
CalogNativeFnT calogFnNative(const CalogFnT *fn);
void calogFnMarkDead(CalogFnT *fn);
uint64_t calogFnOwner(const CalogFnT *fn);
@ -157,6 +180,14 @@ CalogT *calogContextBroker(const CalogContextT *context);
void *calogContextInterp(CalogContextT *context);
bool calogContextRegistered(CalogT *runtime, uint64_t ctxId);
// Every callable a context creates is listed against that context, so the context can reclaim the
// engine handles it still owns before its interpreter goes away (calogFnReclaim). Both are no-ops
// for a host-owned callable (CALOG_HOST_ID), which has no interpreter to outlive, and for an owner
// that can no longer be resolved. Track failing (OOM) fails the create: an untracked callable would
// be exactly the leak this exists to prevent.
int32_t calogContextTrackFn(CalogT *runtime, uint64_t ownerCtxId, CalogFnT *fn);
void calogContextUntrackFn(CalogT *runtime, uint64_t ownerCtxId, CalogFnT *fn);
// ---- per-context resource limits (sandboxing) ----
// The mutable state a limited context enforces on its OWN thread. memUsed is charged by the engine's
// allocator (Lua/QuickJS natively; the rest via a per-VM or process-global counting allocator plus a

View file

@ -14,6 +14,16 @@
// once no launched contexts remain live, calog exits on its own. Absent any of these, calog
// runs until killed.
//
// The exit code is the FIRST one anything asks for -- a script's calogExit, a signal, or a script
// erroring out -- because that is the only rule under which a failure cannot be masked by a later
// calogExit(0), whether it comes from the same script or a sibling.
//
// calogExit does not return. It records the code, then calogAbortAll latches the runtime: the
// engine raises the error it hands back, which unwinds the calling script out of its chunk right
// at the call, and every other context is stopped at its next native call. So the statement after
// calogExit never runs -- a build script's calogExit(1) cannot be followed by more work, nor
// overwritten by a later calogExit(0).
//
// Teardown order matters (see the library headers): the cross-context reference holders
// (export/pubsub/timer/kv) are released while contexts are still alive, THEN calogDestroy joins
// every context thread, THEN the libraries whose registries outlive the runtime (db/net/ssh/
@ -72,19 +82,25 @@ static BOOL WINAPI consoleHandler(DWORD ctrlType);
#endif
static const CalogEngineT *engineForExtension(const char *ext);
static const char *extensionOf(const char *arg);
static int32_t nativeCalogEnd(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static int32_t nativeCalogExit(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static int32_t nativeCalogPrint(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static bool readExitCode(CalogValueT *args, int32_t argCount, CalogValueT *result, int32_t *outCode, bool *outGiven, const char *who);
static void noteExitCode(int32_t code);
static void onError(uint64_t contextId, const char *message, void *userData);
static void onSignal(int sig);
static void printUsage(FILE *stream, const char *program);
static void printValue(const CalogValueT *value);
static char *readFile(const char *path);
static void requestShutdown(int32_t code);
static bool resolveArg(const char *arg, const CalogEngineT **outEngine, char **outSource);
// Requested by a script (calogExit), a signal, or the last live context erroring out; the host
// pump loop watches gShutdown and exits with gExitCode.
static _Atomic bool gShutdown = false;
static _Atomic int32_t gExitCode = 0;
// pump loop watches gShutdown and exits with gExitCode. gExitRequested latches the first request of
// any kind (see noteExitCode) so nothing that follows can rewrite the code it named.
static _Atomic bool gShutdown = false;
static _Atomic bool gExitRequested = false;
static _Atomic int32_t gExitCode = 0;
// One entry per script context the runner launched. onError flags `failed` for a context whose
// script errors; the pump loop then closes that context and drops the live count. When the live
@ -195,22 +211,47 @@ static const char *extensionOf(const char *arg) {
}
// calogExit([code]) -- a script asks the runner to tear everything down and exit. Inline: it
// only sets two atomics, which is thread-safe, so it needs no host hop.
static int32_t nativeCalogExit(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
// calogEnd([code]) -- a script says IT is done, without ending the others. Inline for the same
// reason calogExit is: only a native running on the script's own thread can unwind that script.
// Does not return. Its context retires, so the runner reaps it; when the last launched script has
// ended, the runner exits. A code given here names the process's status the same way calogExit's
// does (first request wins); omitting it claims nothing, leaving the status to whatever else happens.
static int32_t nativeCalogEnd(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
int32_t code;
bool given;
(void)userData;
calogValueNil(result);
if (argCount >= 1) {
if (args[0].type == calogIntE) {
atomic_store(&gExitCode, (int32_t)args[0].as.i);
} else if (args[0].type == calogRealE) {
atomic_store(&gExitCode, (int32_t)args[0].as.r);
} else {
return calogFail(result, calogErrArgE, "calogExit expects an optional integer exit code");
}
if (!readExitCode(args, argCount, result, &code, &given, "calogEnd")) {
return calogErrArgE;
}
atomic_store(&gShutdown, true);
return calogOkE;
if (given) {
noteExitCode(code);
}
return calogAbortCurrent(result);
}
// calogExit([code]) -- a script asks the runner to tear everything down and exit. Registered inline
// so it runs on the CALLING script's thread: that is what lets calogAbortAll unwind this very script
// (an error raised on the host thread could not), and everything it touches is thread-safe anyway.
// Does not return -- the engine raises the error calogAbortAll hands back, aborting the script here.
static int32_t nativeCalogExit(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
CalogT *calog;
int32_t code;
bool given;
(void)userData;
calogValueNil(result);
if (!readExitCode(args, argCount, result, &code, &given, "calogExit")) {
return calogErrArgE;
}
requestShutdown(code);
calog = calogCurrent();
if (calog == NULL) {
return calogOkE; // no calling context to abort (never from a script); the pump loop still ends
}
return calogAbortAll(calog, result);
}
@ -235,6 +276,17 @@ static int32_t nativeCalogPrint(CalogValueT *args, int32_t argCount, CalogValueT
}
// Record what the process will report. Whoever asks FIRST names it -- a script's calogExit, a signal,
// or a script erroring out -- and everything after is ignored. That is the only rule under which a
// failure cannot be masked: neither by a sibling's later calogExit(0), nor by a context erroring out
// while the runtime tears down after a deliberate exit.
static void noteExitCode(int32_t code) {
if (!atomic_exchange(&gExitRequested, true)) {
atomic_store(&gExitCode, code);
}
}
static void onError(uint64_t contextId, const char *message, void *userData) {
int32_t index;
@ -251,10 +303,11 @@ static void onError(uint64_t contextId, const char *message, void *userData) {
// Signal handler for SIGINT/SIGTERM: request the same orderly shutdown as calogExit. Only
// async-signal-safe atomic stores happen here.
// async-signal-safe atomic operations happen here. It does NOT abort the running scripts the way
// calogExit does -- an interrupt is not a script asking to stop, and a signal handler must not run
// the actor layer -- so a script gets to finish its current chunk while the host tears down.
static void onSignal(int sig) {
atomic_store(&gExitCode, (int32_t)(SIGNAL_EXIT_BASE + sig));
atomic_store(&gShutdown, true);
requestShutdown((int32_t)(SIGNAL_EXIT_BASE + sig));
}
@ -264,8 +317,7 @@ static void onSignal(int sig) {
// (reporting a SIGTERM-style exit code) and return TRUE to mark the event handled.
static BOOL WINAPI consoleHandler(DWORD ctrlType) {
(void)ctrlType;
atomic_store(&gExitCode, (int32_t)(SIGNAL_EXIT_BASE + SIGTERM));
atomic_store(&gShutdown, true);
requestShutdown((int32_t)(SIGNAL_EXIT_BASE + SIGTERM));
return TRUE;
}
#endif
@ -365,6 +417,38 @@ static char *readFile(const char *path) {
}
// Read the optional exit code shared by calogEnd and calogExit: *outCode is the code (0 when the
// script named none) and *outGiven says whether it named one at all. Returns false having written
// the diagnostic into result, so the caller returns calogErrArgE.
static bool readExitCode(CalogValueT *args, int32_t argCount, CalogValueT *result, int32_t *outCode, bool *outGiven, const char *who) {
char message[96];
*outCode = 0;
*outGiven = false;
if (argCount < 1) {
return true;
}
if (args[0].type == calogIntE) {
*outCode = (int32_t)args[0].as.i;
} else if (args[0].type == calogRealE) {
*outCode = (int32_t)args[0].as.r;
} else {
snprintf(message, sizeof(message), "%s expects an optional integer exit code", who);
calogFail(result, calogErrArgE, message);
return false;
}
*outGiven = true;
return true;
}
// Ask the pump loop to stop, reporting code if nothing has named one yet (noteExitCode).
static void requestShutdown(int32_t code) {
noteExitCode(code);
atomic_store(&gShutdown, true);
}
// Resolve one command-line argument to an engine + its script source (a fresh buffer the caller
// frees). A recognized extension binds the exact file to that engine; otherwise the argument is
// treated as a base name and searched as <arg>.<ext> across the engines in priority order. On
@ -488,6 +572,7 @@ int main(int argc, char **argv) {
calogSetErrorHandler(calog, onError, NULL);
if (calogRegister(calog, "calogPrint", nativeCalogPrint, NULL) != calogOkE ||
calogRegisterInline(calog, "calogEnd", nativeCalogEnd, NULL) != calogOkE ||
calogRegisterInline(calog, "calogExit", nativeCalogExit, NULL) != calogOkE) {
fprintf(stderr, "calog: failed to register the runner natives\n");
status = 1;
@ -530,11 +615,11 @@ int main(int argc, char **argv) {
context = calogContextOpen(calog, engines[index]);
if (context == NULL) {
fprintf(stderr, "calog: %s: failed to open a context\n", argv[index + 1]);
atomic_store(&gExitCode, 1); // failed launch: never report success on exit
noteExitCode(1); // failed launch: never report success on exit
} else if (calogContextEval(context, sources[index]) != calogOkE) {
fprintf(stderr, "calog: %s: failed to start script\n", argv[index + 1]);
calogContextClose(context);
atomic_store(&gExitCode, 1); // failed launch: never report success on exit
noteExitCode(1); // failed launch: never report success on exit
} else {
gLaunched[gLaunchedCount].id = calogContextId(context);
gLaunched[gLaunchedCount].context = context;
@ -554,19 +639,27 @@ int main(int argc, char **argv) {
}
// Service script->native calls until a script calls calogExit, we are signalled, or every
// launched context has errored out. A script error retires (closes) its own context; when
// the last live context is gone, the runner exits.
// launched context is gone -- each having either ended itself with calogEnd or errored out.
// Both are reaped here; when the last live context is gone, the runner exits.
while (!atomic_load(&gShutdown)) {
calogPump(calog);
calogTaskReap(); // join + free any task that retired itself via taskExit()
for (index = 0; index < gLaunchedCount; index++) {
if (gLaunched[index].context != NULL && atomic_load(&gLaunched[index].failed)) {
int32_t expected;
if (gLaunched[index].context == NULL) {
continue;
}
if (atomic_load(&gLaunched[index].failed)) {
calogContextClose(gLaunched[index].context);
gLaunched[index].context = NULL;
liveCount--;
noteExitCode(1); // a script that errored out: the run did not fully succeed
} else if (calogContextFinished(gLaunched[index].context)) {
// calogEnd: the script said it was done, so its thread has exited. Close joins it
// and frees the context -- the reaping the script asked for. Not a failure, so the
// exit code is left to whatever else names one.
calogContextClose(gLaunched[index].context);
gLaunched[index].context = NULL;
liveCount--;
expected = 0;
atomic_compare_exchange_strong(&gExitCode, &expected, 1);
}
}
if (liveCount == 0) {

View file

@ -106,6 +106,15 @@ struct CalogContextT {
char **allow; // sorted allowed-native names, or NULL = every native permitted
int32_t allowCount;
bool limited; // any limit active (adapter installs an allocator/hook; allow checked)
_Atomic bool aborting; // calogAbortCurrent (calogEnd): this script stops, the runtime lives on
bool closing; // guarded by broker->ctxMutex: a close already owns this context
bool queueClosed; // guarded by queueMutex: serveLoop has stopped serving, refuse new messages
// Every callable this context created, so it can reclaim their engine handles before its
// interpreter is destroyed (contextReclaimCallables). Guarded by queueMutex -- a finalize can
// reach it from a foreign thread on the best-effort path in actorReleaseCallable.
CalogFnT **ownedFns;
int64_t ownedCount;
int64_t ownedCap;
};
// currentContext is the ONLY process-global actor state -- and it is thread-local: it
@ -140,12 +149,14 @@ static void contextDispatchRelease(MessageT *message);
static void contextDrainQueue(CalogContextT *context);
static void contextFreeAllow(CalogContextT *context);
static int32_t contextEnqueue(CalogT *calog, uint64_t targetId, MessageT *message);
static CalogContextT *contextAtIndex(CalogT *calog, int64_t index);
static int32_t contextPostRelease(CalogT *calog, uint64_t targetId, CalogFnT *callable);
static void contextReclaimCallables(CalogContextT *context);
static void contextReply(CalogT *calog, MessageT *message, int32_t status, CalogValueT *result);
static void contextRequestShutdown(CalogContextT *context);
static int32_t contextSendBlocking(CalogT *calog, uint64_t targetId, MessageT *message, CalogValueT *result);
static bool dispatchCommon(CalogContextT *context, MessageT *message);
static void enqueueRaw(CalogContextT *context, MessageT *message);
static bool enqueueRaw(CalogContextT *context, MessageT *message);
static int32_t hookGrow(void **array, int64_t count, int64_t *cap, size_t elemSize);
static void hostDispatch(CalogT *calog, MessageT *message);
static uint64_t idCompose(int64_t index, uint32_t generation);
@ -327,9 +338,13 @@ static void actorReleaseCallable(CalogFnT *callable) {
return;
}
if (contextPostRelease(runtime, owner, callable) != calogOkE) {
// Owner unreachable -- a quiescence violation (design.md sec 9, deferred).
// Best-effort finalize inline.
calogFnFinalize(callable);
// The release could not be marshalled: the owner is unreachable (a quiescence violation --
// design.md sec 9), or the message itself would not allocate while the owner is very much
// alive. We are NOT on the owner's thread either way, so finalizing normally could run the
// adapter's engine op (luaL_unref, JS_FreeValue, ...) against a live interpreter from the
// wrong thread. Free the memory and deliberately leak the engine handle instead: it dies
// with its interpreter, and a leak beats corrupting a VM that is still running.
calogFnFinalizeForeign(callable);
}
}
@ -358,12 +373,47 @@ static int32_t actorRoute(CalogT *calog, CalogEntryT *entry, CalogValueT *args,
void calogActorShutdown(CalogT *calog) {
int64_t index;
// How long to park between checks while waiting out a close that is already in flight (0.5 ms,
// the same tick the host pump uses). It is a rendezvous that almost never happens.
struct timespec closeWait = { 0, 500000 };
int64_t count;
int64_t index;
for (index = 0; index < calog->ctxCount; index++) {
// Latch the registry closed and read its extent in the same breath. Until now a script was free
// to keep opening contexts (taskSpawn, taskLoad); from here calogContextOpen refuses, so the
// slots below are the complete and final set, ctxSlots cannot move under a realloc, and no
// thread can be started that these loops would miss and then free out from under itself.
pthread_mutex_lock(&calog->ctxMutex);
calog->tearingDown = true;
count = calog->ctxCount;
pthread_mutex_unlock(&calog->ctxMutex);
// A close that was already running when the latch went up (a script's taskClose) owns that
// context's stop-join-free and unlinks its slot when it is done. Wait those out rather than race
// them -- joining one thread from two is undefined, and freeing a context another thread is
// still inside is worse. The latch refuses any new one, so this settles and cannot grow.
for (;;) {
bool pending;
pending = false;
pthread_mutex_lock(&calog->ctxMutex);
for (index = 0; index < count; index++) {
if (calog->ctxSlots[index].context != NULL && calog->ctxSlots[index].context->closing) {
pending = true;
break;
}
}
pthread_mutex_unlock(&calog->ctxMutex);
if (!pending) {
break;
}
nanosleep(&closeWait, NULL);
}
for (index = 0; index < count; index++) {
CalogContextT *context;
context = calog->ctxSlots[index].context;
if (context == NULL || !context->started) {
context = contextAtIndex(calog, index);
if (context == NULL) {
continue;
}
contextRequestShutdown(context);
@ -371,25 +421,25 @@ void calogActorShutdown(CalogT *calog) {
// Join EVERY thread before freeing ANY context: a context tearing down may release a
// callable owned by a sibling (marshalled across contexts), which resolves/posts to that
// sibling -- so no sibling may be freed while another's thread still runs.
for (index = 0; index < calog->ctxCount; index++) {
for (index = 0; index < count; index++) {
CalogContextT *context;
context = calog->ctxSlots[index].context;
if (context != NULL && context->started) {
context = contextAtIndex(calog, index);
if (context != NULL) {
pthread_join(context->thread, NULL);
}
}
// All context threads are stopped. Unregister each under ctxMutex (so any in-flight
// registryResolveLocked from the host sees NULL rather than a freed slot), drain any
// orphaned release, then free.
for (index = 0; index < calog->ctxCount; index++) {
for (index = 0; index < count; index++) {
CalogContextT *context;
context = calog->ctxSlots[index].context;
pthread_mutex_lock(&calog->ctxMutex);
context = calog->ctxSlots[index].context;
calog->ctxSlots[index].context = NULL;
pthread_mutex_unlock(&calog->ctxMutex);
if (context == NULL) {
continue;
}
pthread_mutex_lock(&calog->ctxMutex);
calog->ctxSlots[index].context = NULL;
pthread_mutex_unlock(&calog->ctxMutex);
contextDrainQueue(context);
contextFreeAllow(context);
pthread_mutex_destroy(&context->queueMutex);
@ -404,7 +454,6 @@ void calogActorShutdown(CalogT *calog) {
calog->ctxCap = 0;
calog->ctxFreeCount = 0;
calog->ctxFreeCap = 0;
pthread_mutex_destroy(&calog->ctxMutex);
// Drain and free the host context. Only clear the calling thread's currentContext
// if it named THIS runtime's host -- the thread may still host other runtimes.
@ -427,6 +476,9 @@ void calogActorShutdown(CalogT *calog) {
calog->releaseHook = NULL;
calog->errorHandler = NULL;
calog->errorUserData = NULL;
// Last: draining the host queue above can finalize a callable, and that path resolves the owner
// under ctxMutex (it finds nothing now that every slot is gone), so the mutex has to outlive it.
pthread_mutex_destroy(&calog->ctxMutex);
}
@ -625,6 +677,13 @@ CalogContextT *calogContextOpenLimited(CalogT *broker, const CalogEngineT *engin
}
pthread_mutex_lock(&broker->ctxMutex);
// The runtime is being destroyed: refuse rather than join a registry that is already being
// walked and freed (a taskSpawn racing calogDestroy would otherwise leave a thread running
// past the teardown that never saw it).
if (broker->tearingDown) {
pthread_mutex_unlock(&broker->ctxMutex);
goto fail;
}
if (broker->ctxFreeCount > 0) {
broker->ctxFreeCount--;
index = broker->ctxFree[broker->ctxFreeCount];
@ -653,16 +712,19 @@ CalogContextT *calogContextOpenLimited(CalogT *broker, const CalogEngineT *engin
context->id = idCompose(index, generation);
broker->ctxSlots[index].context = context;
broker->ctxSlots[index].generation = generation;
pthread_mutex_unlock(&broker->ctxMutex);
// Start the thread and publish `started` INSIDE the registry lock. A teardown reads both under
// that same lock, so it can never catch this context half-registered -- either the slot is still
// empty, or it is filled and the thread it names is running. The new thread may need the lock
// itself (its first callable, a context init hook); it simply waits the moment or two until the
// unlock below, and nothing here waits on the new thread, so the two cannot deadlock.
if (pthread_create(&context->thread, NULL, threadMain, context) != 0) {
pthread_mutex_lock(&broker->ctxMutex);
broker->ctxSlots[index].context = NULL;
registryFreePush(broker, index);
pthread_mutex_unlock(&broker->ctxMutex);
goto fail;
}
context->started = true;
pthread_mutex_unlock(&broker->ctxMutex);
return context;
fail:
@ -778,6 +840,54 @@ bool calogCurrentShuttingDown(void) {
}
// Latch the runtime aborting so no context runs another line of script (see calog.h). Called from a
// native: the engine raises the error returned here, which unwinds the calling script out of its
// chunk at the call site, and every later call -- from this context or any other -- is refused by
// calogCall/calogFnInvoke, so a script that catches the unwind still cannot do anything.
// Idempotent: latching a latched runtime is a no-op.
//
// It deliberately does NOT retire the calling context. Teardown order is the host's (calogDestroy
// releases the cross-context reference holders -- export/pubsub/timer -- while every context is
// still alive, and only then joins them). A context that closed itself here would destroy its own
// interpreter first, stranding the callables those registries still hold: harmless for a VM that
// frees everything on close, fatal for one that asserts it owns no live objects (QuickJS). The
// scripts are stopped either way -- that is what the latch is for.
int32_t calogAbortAll(CalogT *calog, CalogValueT *result) {
atomic_store(&calog->aborting, true);
return calogFail(result, calogErrAbortE, CALOG_ABORT_MESSAGE);
}
// Stop just the CALLING script (see calog.h). Same unwind as calogAbortAll -- the native returns
// this, the engine raises it, the script ends at the call -- but scoped to one context: the runtime
// and every other script keep running. The context also retires, so its thread ends once this eval
// unwinds and the host can reap it. Safe to retire from here because the thread reclaims the engine
// handles it owns on its way out (contextReclaimCallables), so nothing it published outlives its VM.
int32_t calogAbortCurrent(CalogValueT *result) {
if (currentContext == NULL || currentContext->id == CALOG_HOST_ID) {
return calogFail(result, calogErrUnsupportedE, "calogAbortCurrent: no calling script to end");
}
atomic_store(&currentContext->aborting, true);
calogCurrentRetire();
return calogFail(result, calogErrAbortE, CALOG_ABORT_MESSAGE);
}
// True when the caller must stop running script code: this runtime was latched (calogAbortAll), or
// the calling context ended itself (calogAbortCurrent). One query for both, so the dispatch choke
// points and every engine adapter ask the same question. On the host thread, or on a thread whose
// context belongs to another runtime, only the runtime latch applies.
bool calogAborting(CalogT *calog) {
if (atomic_load(&calog->aborting)) {
return true;
}
if (currentContext == NULL || currentContext->broker != calog) {
return false;
}
return atomic_load(&currentContext->aborting);
}
// Tear down a single context (quiescence assumed). The thread is stopped and joined,
// then the slot is unlinked under the registry lock so no foreign enqueue can reach
// the freed queue mutex; the freed index returns to the freelist.
@ -789,6 +899,18 @@ void calogContextClose(CalogContextT *context) {
return;
}
broker = context->broker;
// Exactly one closer may stop, join and free a given context. A script thread reaches this
// through taskClose, which can land while calogDestroy is tearing the runtime down -- and two
// threads joining one pthread is undefined. So claim it here, under the registry lock, or leave
// it to whoever holds the claim: the teardown (tearingDown is its claim on every context, and it
// waits out any close already in flight) or another close already running.
pthread_mutex_lock(&broker->ctxMutex);
if (broker->tearingDown || context->closing) {
pthread_mutex_unlock(&broker->ctxMutex);
return;
}
context->closing = true;
pthread_mutex_unlock(&broker->ctxMutex);
if (context->started) {
contextRequestShutdown(context);
pthread_join(context->thread, NULL);
@ -808,6 +930,21 @@ void calogContextClose(CalogContextT *context) {
}
// Read one registry slot with ctxMutex held. Teardown walks the registry from the host thread while
// script threads can still be unlinking contexts of their own, so every slot read has to take the
// lock -- an unlocked walk races an open's realloc of the whole array. calogContextOpen fills a slot
// and starts that context's thread inside this same lock, so a slot found here always names a
// context whose thread is running and is therefore joinable.
static CalogContextT *contextAtIndex(CalogT *calog, int64_t index) {
CalogContextT *context;
pthread_mutex_lock(&calog->ctxMutex);
context = calog->ctxSlots[index].context;
pthread_mutex_unlock(&calog->ctxMutex);
return context;
}
static int32_t contextDispatch(CalogT *calog, uint64_t targetId, CalogNativeFnT fn, void *userData, CalogValueT *args, int32_t argCount, CalogValueT *result) {
MessageT *call;
int32_t status;
@ -944,9 +1081,12 @@ static int32_t contextEnqueue(CalogT *calog, uint64_t targetId, MessageT *messag
pthread_mutex_lock(&calog->ctxMutex);
target = registryResolveLocked(calog, targetId);
if (target != NULL) {
enqueueRaw(target, message);
bool queued;
queued = enqueueRaw(target, message);
pthread_mutex_unlock(&calog->ctxMutex);
return calogOkE;
// A resolvable context whose queue has closed is on its way out: dead for our purposes, and
// reported as such rather than accepting a message no one will serve.
return queued ? calogOkE : calogErrDeadE;
}
// Resolve failed: an in-range index means the slot existed and has since been
// freed or recycled (a dead context); out of range means the id named nothing.
@ -1036,6 +1176,67 @@ bool calogContextRegistered(CalogT *runtime, uint64_t ctxId) {
}
// List a new callable against the context that owns it, so contextReclaimCallables can release its
// engine handle if that context is torn down while something else still holds it. Locks in the
// runtime's established order (ctxMutex, then the context's queueMutex). An owner that no longer
// resolves needs no entry: its interpreter is already gone, which calogFnFinalize handles.
int32_t calogContextTrackFn(CalogT *runtime, uint64_t ownerCtxId, CalogFnT *fn) {
CalogContextT *owner;
void *buffer;
int64_t capacity;
int32_t status;
if (runtime == NULL || ownerCtxId == CALOG_HOST_ID) {
return calogOkE; // host-owned: no interpreter for the handle to outlive
}
status = calogOkE;
pthread_mutex_lock(&runtime->ctxMutex);
owner = registryResolveLocked(runtime, ownerCtxId);
if (owner != NULL) {
pthread_mutex_lock(&owner->queueMutex);
buffer = owner->ownedFns;
capacity = owner->ownedCap;
status = calogGrow(&buffer, &capacity, owner->ownedCount + 1, sizeof(CalogFnT *));
if (status == calogOkE) {
owner->ownedFns = (CalogFnT **)buffer;
owner->ownedCap = capacity;
owner->ownedFns[owner->ownedCount] = fn;
owner->ownedCount++;
}
pthread_mutex_unlock(&owner->queueMutex);
}
pthread_mutex_unlock(&runtime->ctxMutex);
return status;
}
// Drop a callable from its owner's list as it is finalized. Order within the list carries no
// meaning, so the hole is filled from the end. A callable the owner already reclaimed is not in any
// list (the sweep took the whole array), which is why this can run for one and simply find nothing.
void calogContextUntrackFn(CalogT *runtime, uint64_t ownerCtxId, CalogFnT *fn) {
CalogContextT *owner;
int64_t index;
if (runtime == NULL || ownerCtxId == CALOG_HOST_ID) {
return;
}
pthread_mutex_lock(&runtime->ctxMutex);
owner = registryResolveLocked(runtime, ownerCtxId);
if (owner != NULL) {
pthread_mutex_lock(&owner->queueMutex);
for (index = 0; index < owner->ownedCount; index++) {
if (owner->ownedFns[index] == fn) {
owner->ownedCount--;
owner->ownedFns[index] = owner->ownedFns[owner->ownedCount];
break;
}
}
pthread_mutex_unlock(&owner->queueMutex);
}
pthread_mutex_unlock(&runtime->ctxMutex);
}
void calogPump(CalogT *calog) {
CalogContextT *previous;
MessageT *message;
@ -1084,6 +1285,43 @@ void calogSetErrorHandler(CalogT *calog, CalogErrorFnT fn, void *userData) {
// The reply tail shared by CALL dispatch: hand (status, result) back to whoever is
// blocked on this message -- an external caller's reply box, or a context caller via
// a REPLY enqueued onto its queue (in the serving context's runtime). Consumes message.
// Release the engine handle of every callable this context still owns, on the context's OWN thread,
// while its interpreter is still alive. Anything else holding one of these callables -- a pubsub
// subscription, an export, a value handed to another engine, an invoke in flight -- keeps a valid
// (dead) shell to release later; without this the engine handle would outlive the VM that owns it,
// which most VMs quietly tolerate and QuickJS aborts on. See design.md sec 26.
static void contextReclaimCallables(CalogContextT *context) {
CalogFnT **owned;
int64_t count;
int64_t index;
// Take the whole list first: an engine release can cascade (a VM finalizer dropping another of
// this context's callables), which re-enters untrack, and must not run against a list being
// walked. Retaining each entry over the sweep keeps a cascade from freeing one we have not
// reached yet.
pthread_mutex_lock(&context->queueMutex);
owned = context->ownedFns;
count = context->ownedCount;
context->ownedFns = NULL;
context->ownedCount = 0;
context->ownedCap = 0;
pthread_mutex_unlock(&context->queueMutex);
if (owned == NULL) {
return;
}
for (index = 0; index < count; index++) {
calogFnRetain(owned[index]);
}
for (index = 0; index < count; index++) {
calogFnReclaim(owned[index]);
}
for (index = 0; index < count; index++) {
calogFnRelease(owned[index]); // reclaimed above, so a final drop frees the shell only
}
free(owned);
}
static void contextReply(CalogT *calog, MessageT *message, int32_t status, CalogValueT *result) {
if (message->replyBox != NULL) {
ReplyBoxT *box;
@ -1216,8 +1454,14 @@ static bool dispatchCommon(CalogContextT *context, MessageT *message) {
}
static void enqueueRaw(CalogContextT *context, MessageT *message) {
// Append to a context's queue, or refuse if that context has stopped serving. Returns false only for
// the refusal; the caller owns the message either way.
static bool enqueueRaw(CalogContextT *context, MessageT *message) {
pthread_mutex_lock(&context->queueMutex);
if (context->queueClosed) {
pthread_mutex_unlock(&context->queueMutex);
return false;
}
message->next = NULL;
if (context->tail != NULL) {
context->tail->next = message;
@ -1227,6 +1471,7 @@ static void enqueueRaw(CalogContextT *context, MessageT *message) {
context->tail = message;
pthread_cond_signal(&context->queueCond);
pthread_mutex_unlock(&context->queueMutex);
return true;
}
@ -1270,6 +1515,12 @@ static MessageT *messageDequeue(CalogContextT *context) {
pthread_cond_wait(&context->queueCond, &context->queueMutex);
}
if (context->head == NULL) {
// Queue drained and shutting down: this thread is about to leave serveLoop. Close the queue
// in the SAME critical section that decides to stop, so a sender can never slip a message in
// between -- one that nothing would ever serve, leaving a blocking caller waiting on a reply
// that cannot come. From here contextEnqueue reports the context dead, which every caller
// already handles.
context->queueClosed = true;
pthread_mutex_unlock(&context->queueMutex);
return NULL;
}
@ -1489,6 +1740,10 @@ static void *threadMain(void *arg) {
context->broker->contextHooks[index].shutdown(context, context->broker->contextHooks[index].userData);
}
}
// Reclaim the engine handle of every callable this context still owns, while the interpreter is
// alive and this is its own thread. Anything still holding one -- a subscription, an export, a
// value another engine received -- is left a dead shell it can safely release later.
contextReclaimCallables(context);
// Mark the interpreter dead BEFORE destroying it, so registryResolveLocked stops handing
// this context out: a callable this context owns that is released from now on (e.g. one
// published via the export library and dropped after the context unloads) finalizes by

View file

@ -34,6 +34,10 @@
#include <string.h>
#include <time.h>
// Starting size of the buffer that captures what janet_dobytes reports (an error line plus a stack
// trace). It grows as needed, so this only saves a few early reallocations.
#define JANET_ERROR_CAPTURE_CAP 256
struct CalogJanetT {
JanetTable *env;
CalogT *broker;
@ -809,12 +813,37 @@ static int32_t janetWrapFunction(CalogJanetT *context, JanetFunction *function,
int32_t calogJanetRun(CalogJanetT *context, const char *source) {
Janet out;
int flags;
JanetBuffer *captured;
Janet errKey;
Janet out;
bool aborted;
int32_t status;
int flags;
gJanetInterrupted = false; // one-shot per run: a fresh over-cap interrupt may fire again
// Unlike every other engine, Janet reports a failed script itself, from inside janet_dobytes
// (the error line plus a stack trace, through janet_eprintf). That is the right output for a
// script that failed and exactly the wrong output for one that calogExit unwound on purpose, so
// capture it and decide afterwards. janet_eprintf honors the "err" dynamic binding; the binding
// must go in the TOP dyn table, because janet_dobytes prints once janet_continue has returned
// and no fiber is current. A script's own (eprint) still goes straight to stderr -- inside a
// fiber the lookup finds this env's bindings, not the top table.
captured = janet_buffer(JANET_ERROR_CAPTURE_CAP);
errKey = janet_ckeywordv("err");
// Janet does not mark the top dyn table, so an entry parked there is invisible to the collector:
// root both halves by hand or a collection inside the script frees them under the binding.
janet_gcroot(errKey);
janet_gcroot(janet_wrap_buffer(captured));
janet_setdyn("err", janet_wrap_buffer(captured));
out = janet_wrap_nil();
flags = janet_dobytes(context->env, (const uint8_t *)source, (int32_t)strlen(source), "calog", &out);
janet_setdyn("err", janet_wrap_nil());
// calogExit (calogAbortAll) unwinding this script is not a failure: drop what Janet reported and
// hand back success, so the runner neither prints a trace nor retires the context as broken.
// Only a RUNTIME trip can be an abort -- a parse or compile error still reports, mid-teardown or
// not, since no native ran to ask for one.
aborted = ((flags & JANET_DO_ERROR_RUNTIME) != 0) && calogAborting(context->broker);
status = (flags != 0 && !aborted) ? calogErrArgE : calogOkE;
if (flags != 0) {
// If a sandbox limit tripped -- the allocator interrupted the VM this run (gJanetInterrupted,
// which survives a GC that later drops memUsed back under the cap) or the wall-clock deadline
@ -829,9 +858,12 @@ int32_t calogJanetRun(CalogJanetT *context, const char *source) {
calogCurrentRetire();
}
}
// janet_dobytes already printed the error + a stack trace to stderr (janet_eprintf), so
// do not print it again here; just report failure through the single error channel.
return calogErrArgE;
if (status != calogOkE) {
// Relayed verbatim, so a failing janet script reads exactly as it did before.
fwrite(captured->data, 1, (size_t)captured->count, stderr);
}
}
return calogOkE;
janet_gcunroot(janet_wrap_buffer(captured));
janet_gcunroot(errKey);
return status;
}

View file

@ -448,7 +448,14 @@ int32_t calogJsRun(CalogJsT *context, const char *source) {
if (JS_IsException(val)) {
JSValue exc;
const char *message;
exc = JS_GetException(ctx);
exc = JS_GetException(ctx);
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, and the
// runtime is already tearing down, so report nothing.
if (calogAborting(context->broker)) {
JS_FreeValue(ctx, exc);
JS_FreeValue(ctx, val);
return calogOkE;
}
message = JS_ToCString(ctx, exc);
fprintf(stderr, "js eval error: %s\n", message != NULL ? message : "(unknown)");
if (message != NULL) {

View file

@ -471,6 +471,12 @@ int32_t calogLuaRun(CalogLuaT *context, const char *source) {
return calogErrArgE;
}
if (lua_pcall(L, 0, 0, 0) != LUA_OK) {
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, and the
// runtime is already tearing down, so report nothing.
if (calogAborting(context->broker)) {
lua_pop(L, 1);
return calogOkE;
}
fprintf(stderr, "lua run error: %s\n", lua_tostring(L, -1));
lua_pop(L, 1);
return calogErrArgE;

View file

@ -639,6 +639,14 @@ int32_t calogMrubyRun(CalogMrubyT *context, const char *source) {
if (mrb->exc != NULL) {
mrb_value exc;
mrb_value text;
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, and the runtime
// is already tearing down, so report nothing -- and do not run inspect, which would re-enter
// a VM whose every native call is now refused.
if (calogAborting(context->broker)) {
mrb->exc = NULL;
mrb_gc_arena_restore(mrb, arena);
return calogOkE;
}
exc = mrb_obj_value(mrb->exc);
mrb->exc = NULL;
text = mrb_funcall_argv(mrb, exc, mrb_intern_lit(mrb, "inspect"), 0, NULL);

View file

@ -1911,6 +1911,11 @@ int32_t calogMyBasicRun(CalogMyBasicT *context, const char *source) {
}
code = mb_run(context->bas, true);
if (code != MB_FUNC_OK) {
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, and the
// runtime is already tearing down, so report nothing.
if (calogAborting(context->broker)) {
return calogOkE;
}
mbReportError(context, "run");
return calogErrArgE;
}

View file

@ -540,6 +540,11 @@ int32_t calogS7Run(CalogS7T *context, const char *source) {
s7_eval_c_string(sc, wrapped);
free(wrapped);
if (context->errorFlag) {
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, and the
// runtime is already tearing down, so report nothing.
if (calogAborting(context->broker)) {
return calogOkE;
}
fprintf(stderr, "s7 error: %s\n", context->errorText);
return calogErrArgE;
}

View file

@ -528,6 +528,12 @@ int32_t calogSquirrelRun(CalogSquirrelT *context, const char *source) {
}
sq_pushroottable(v);
if (SQ_FAILED(sq_call(v, 1, SQFalse, SQTrue))) {
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, and the
// runtime is already tearing down, so report nothing.
if (calogAborting(context->broker)) {
sq_settop(v, baseTop);
return calogOkE;
}
squirrelReportRuntimeError(v, "squirrel run error");
sq_settop(v, baseTop);
return calogErrArgE;

View file

@ -845,6 +845,11 @@ int32_t calogTclRun(CalogTclT *context, const char *source) {
code = Tcl_EvalEx(context->interp, source, -1, TCL_EVAL_GLOBAL);
if (code != TCL_OK) {
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, and the
// runtime is already tearing down, so report nothing.
if (calogAborting(context->broker)) {
return calogOkE;
}
fprintf(stderr, "tcl error: %s\n", Tcl_GetString(Tcl_GetObjResult(context->interp)));
return calogErrArgE;
}

View file

@ -191,6 +191,7 @@ int32_t calogAggSet(CalogAggT *aggregate, CalogValueT *key, CalogValueT *value)
int32_t calogFnCreate(CalogFnT **out, CalogT *runtime, CalogNativeFnT fn, void *userData, CalogReleaseFnT release, uint64_t ownerCtxId) {
CalogFnT *callable;
int32_t status;
*out = NULL;
callable = (CalogFnT *)calloc(1, sizeof(*callable));
@ -204,6 +205,14 @@ int32_t calogFnCreate(CalogFnT **out, CalogT *runtime, CalogNativeFnT fn, void *
callable->ownerCtxId = ownerCtxId;
atomic_init(&callable->refCount, CALLABLE_INITIAL_REFCOUNT);
atomic_init(&callable->alive, true);
// List it against the owning context, which reclaims the engine handle if it is torn down while
// something else still holds this callable (design.md sec 26). No list, no reclaim -- so a
// failure here fails the create rather than handing back a callable that could strand its handle.
status = calogContextTrackFn(runtime, ownerCtxId, callable);
if (status != calogOkE) {
free(callable);
return status;
}
*out = callable;
return calogOkE;
}
@ -229,6 +238,11 @@ void calogFnFinalize(CalogFnT *callable) {
//
// (Freeing userData directly requires every engine's per-callable struct to be a single
// heap allocation; all adapters honor that -- see CalogReleaseFnT in calogInternal.h.)
//
// A callable the owner already RECLAIMED (calogFnReclaim, as its interpreter was torn down)
// has no release hook left, so this frees only the shell -- the engine handle went with the
// interpreter that owned it.
calogContextUntrackFn(callable->runtime, callable->ownerCtxId, callable);
if (callable->release != NULL) {
if (callable->ownerCtxId == CALOG_HOST_ID || calogContextRegistered(callable->runtime, callable->ownerCtxId)) {
callable->release(callable);
@ -240,6 +254,25 @@ void calogFnFinalize(CalogFnT *callable) {
}
// The owning context is tearing its interpreter down; this runs on that context's own thread, with
// the interpreter still alive, for every callable the context still owns. Running the engine release
// HERE is what keeps the handle from outliving its VM: whoever else still holds a reference -- a
// pubsub subscription, an export, a value sitting in another engine, an invoke in flight -- finds a
// dead callable and finalizes an empty shell. Marking it dead first makes any such invoke fail
// cleanly instead of reaching into a VM that is going away.
void calogFnReclaim(CalogFnT *callable) {
if (callable == NULL) {
return;
}
calogFnMarkDead(callable);
if (callable->release != NULL) {
callable->release(callable); // frees the engine handle AND the adapter's userData block
callable->release = NULL;
callable->userData = NULL;
}
}
CalogNativeFnT calogFnNative(const CalogFnT *callable) {
return callable->fn;
}
@ -247,8 +280,18 @@ CalogNativeFnT calogFnNative(const CalogFnT *callable) {
int32_t calogFnInvoke(CalogFnT *callable, CalogValueT *args, int32_t argCount, CalogValueT *result) {
calogValueNil(result);
// The owner reclaimed this callable as its interpreter went away (calogFnReclaim), so there is
// nothing left to call. calogErrDeadE is the same answer the routing path gives for a context
// that is gone -- the timer library cancels a timer on exactly that, and pubsub stops counting
// the subscriber as delivered.
if (!atomic_load_explicit(&callable->alive, memory_order_acquire)) {
return calogFail(result, calogErrNotFoundE, "callable owner no longer exists");
return calogFail(result, calogErrDeadE, "callable owner no longer exists");
}
// The caller has been stopped (calogAbortAll, or this script's own calogAbortCurrent): a script
// function is script code, so refuse it for the same reason calogCall refuses a native -- a timer
// or subscriber callback already queued must not start running a script body afterwards.
if (callable->runtime != NULL && calogAborting(callable->runtime)) {
return calogFail(result, calogErrAbortE, CALOG_ABORT_MESSAGE);
}
// The owning runtime's actor layer, if present, marshals a foreign-thread invoke
// to the owner's thread; inline otherwise (a bare broker has no hook).
@ -259,6 +302,29 @@ int32_t calogFnInvoke(CalogFnT *callable, CalogValueT *args, int32_t argCount, C
}
// Finalize a callable WITHOUT running the engine release, for a caller that is not on the owner's
// thread and could not marshal the release there (actorReleaseCallable's fallback). The handle
// INSIDE the interpreter is deliberately left behind -- it is pinned until that interpreter is
// destroyed, which for a long-lived context can be a long time, but leaking beats reaching into a
// live VM from the wrong thread.
//
// userData is freed on exactly the same condition as calogFnFinalize: only when a release hook
// exists. That hook is what makes userData calog's to free (an adapter's single heap block, per the
// CalogReleaseFnT contract). A host-owned callable from calogFnFromNative has NO hook and a userData
// the EMBEDDER owns -- freeing that would corrupt their heap, which is worse than anything this
// function exists to prevent. A callable already reclaimed has both cleared, so it frees the shell.
void calogFnFinalizeForeign(CalogFnT *callable) {
if (callable == NULL) {
return;
}
calogContextUntrackFn(callable->runtime, callable->ownerCtxId, callable);
if (callable->release != NULL) {
free(callable->userData);
}
free(callable);
}
void calogFnMarkDead(CalogFnT *callable) {
if (callable == NULL) {
return;

View file

@ -566,6 +566,14 @@ int32_t calogWrenRun(CalogWrenT *context, const char *source) {
context->errorMsg = NULL;
result = wrenInterpret(vm, "main", source);
if (result != WREN_RESULT_SUCCESS) {
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, and the
// runtime is already tearing down, so report nothing. Wren reports a compile failure as its
// own result, so a syntax error still gets its diagnostic even mid-teardown.
if (result == WREN_RESULT_RUNTIME_ERROR && calogAborting(context->broker)) {
free(context->errorMsg);
context->errorMsg = NULL;
return calogOkE;
}
fprintf(stderr, "wren error: %s\n", context->errorMsg != NULL ? context->errorMsg : "(unknown)");
free(context->errorMsg);
context->errorMsg = NULL;

View file

@ -273,7 +273,9 @@ static void testCallableDead(void) {
calogFnMarkDead(callable);
status = calogFnInvoke(callable, NULL, 0, &result);
CHECK(status == calogErrNotFoundE, "invoke on dead callable returns not-found");
// calogErrDeadE, the same answer the actor layer gives for a callable whose context is gone --
// one status for "the owner is gone", which is what the timer library cancels a timer on.
CHECK(status == calogErrDeadE, "invoke on dead callable reports the owner is gone");
CHECK(result.type == calogStringE, "dead invoke leaves an error string");
calogValueFree(&result);

238
tests/testExit.c Normal file
View file

@ -0,0 +1,238 @@
// testExit.c -- calogAbortAll: one native stops every script in the runtime.
//
// This is the contract API.md states for the runner's calogExit: it DOES NOT RETURN. A native that
// hands back calogAbortAll's value must unwind the CALLING script out of its chunk right at the call
// site, on every engine -- so each script below calls stopAll() and then spins in an infinite loop.
// The loop is the evidence: if the abort unwound the script it is never entered and the context
// retires within milliseconds, while a native that merely returned would pin that context forever.
// Native calls cannot serve as evidence here, because the latch refuses them either way.
//
// The same run also proves the three properties the abort must have: an aborted script is NOT
// reported to the error handler (it did not fail, and its engine must print nothing either), the
// runtime stays latched afterwards, and every later native call is refused instead of served.
#define _POSIX_C_SOURCE 200809L
#include "calog.h"
#include <stdatomic.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <time.h>
#define CHECK(cond, msg) checkImpl((cond), (msg), __LINE__)
// s7 interns a small, bounded set of "permanent" strings it never reclaims (an s7 trait, not a
// leak), so suppress exactly that site to keep the leak check meaningful. LSan calls this weak hook.
const char *__lsan_default_suppressions(void);
const char *__lsan_default_suppressions(void) {
return "leak:make_permanent_string\n";
}
// A retiring context is joined by its own thread returning, so the wait is short in the passing
// case; the budget only has to cover a loaded machine (4000 * 0.5 ms = 2 s).
#define PUMP_INTERVAL_NS 500000
#define PUMP_LIMIT 4000
// One engine's script: reach mark(), abort, then loop forever if the abort let the script continue.
typedef struct EngineCaseT {
const CalogEngineT *engine;
const char *name;
const char *source;
} EngineCaseT;
static _Atomic int32_t markCount = 0;
static _Atomic int32_t errorCount = 0;
static int32_t testsRun = 0;
static int32_t testsFailed = 0;
static void checkEngine(bool condition, const char *engineName, const char *what, int32_t line);
static void checkImpl(bool condition, const char *message, int32_t line);
static int32_t nativeMark(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static int32_t nativeStopAll(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static void onError(uint64_t contextId, const char *message, void *userData);
static void runCase(const EngineCaseT *item);
static void testLatchIsPerRuntime(void);
static void checkEngine(bool condition, const char *engineName, const char *what, int32_t line) {
char message[160];
snprintf(message, sizeof(message), "%s: %s", engineName, what);
checkImpl(condition, message, line);
}
static void checkImpl(bool condition, const char *message, int32_t line) {
testsRun++;
if (!condition) {
testsFailed++;
printf("FAIL testExit.c:%d %s\n", line, message);
}
}
// Host native: records that the script got this far. Registered like the runner's calogPrint, so the
// script blocks on the host thread for it -- the ordinary path a script reaches C through.
static int32_t nativeMark(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
(void)args;
(void)argCount;
(void)userData;
atomic_fetch_add(&markCount, 1);
calogValueNil(result);
return calogOkE;
}
// Inline native: what the runner's calogExit does, minus recording an exit code. Inline is what
// makes the abort reach THIS script -- an error raised on the host thread could not unwind it.
//
// calogCurrentRetire is the test's probe, not part of what calogExit does. Retirement is serviced
// only AFTER the current eval returns, so "this context's thread exited" is precisely "the eval
// returned" -- which is the property under test, and the one thing a script cannot report itself
// once every native call is refused. A script that kept running would sit in its loop forever and
// its thread would never exit.
static int32_t nativeStopAll(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
(void)args;
(void)argCount;
(void)userData;
calogValueNil(result);
calogCurrentRetire();
return calogAbortAll(calogCurrent(), result);
}
static void onError(uint64_t contextId, const char *message, void *userData) {
(void)contextId;
(void)userData;
atomic_fetch_add(&errorCount, 1);
printf(" (error handler saw: %s)\n", message != NULL ? message : "(null)");
}
// Run one engine's script on its own runtime (the latch is one-way, so each engine needs a fresh
// one), then check what the abort did.
static void runCase(const EngineCaseT *item) {
CalogT *calog;
CalogContextT *ctx;
CalogValueT result;
struct timespec tick = { 0, PUMP_INTERVAL_NS };
int32_t index;
bool finished;
atomic_store(&markCount, 0);
atomic_store(&errorCount, 0);
calog = calogCreate();
if (calog == NULL) {
checkEngine(false, item->name, "runtime create failed", __LINE__);
return;
}
calogSetErrorHandler(calog, onError, NULL);
calogRegister(calog, "mark", nativeMark, NULL);
calogRegisterInline(calog, "stopAll", nativeStopAll, NULL);
ctx = calogContextOpen(calog, item->engine);
if (ctx == NULL) {
checkEngine(false, item->name, "context open failed", __LINE__);
calogDestroy(calog);
return;
}
calogContextEval(ctx, item->source);
finished = false;
for (index = 0; index < PUMP_LIMIT; index++) {
calogPump(calog);
if (calogContextFinished(ctx)) {
finished = true;
break;
}
nanosleep(&tick, NULL);
}
calogPump(calog);
checkEngine(atomic_load(&markCount) == 1, item->name, "the script ran up to the abort", __LINE__);
checkEngine(finished, item->name, "the abort unwound the script -- the loop after it never ran", __LINE__);
checkEngine(atomic_load(&errorCount) == 0, item->name, "an aborted script is not reported as an error", __LINE__);
checkEngine(calogAborting(calog), item->name, "the runtime stays latched aborting", __LINE__);
calogValueNil(&result);
checkEngine(calogCall(calog, "mark", NULL, 0, &result) == calogErrAbortE, item->name, "a later native call is refused", __LINE__);
calogValueFree(&result);
checkEngine(atomic_load(&markCount) == 1, item->name, "the refused call never reached the native", __LINE__);
// A context that did NOT retire is still spinning in that infinite loop, and closing it would
// join a thread that never returns -- hanging the test instead of reporting the failure. Leak it
// (the check above has already failed) and move on to the next engine.
if (finished) {
calogContextClose(ctx);
calogDestroy(calog);
}
}
// The latch belongs to ONE runtime: aborting a runtime must not stop scripts in a second one that
// happens to share the process. This also covers calogAbortAll called from the host thread, which
// has no script of its own to unwind.
static void testLatchIsPerRuntime(void) {
CalogT *first;
CalogT *second;
CalogValueT result;
first = calogCreate();
second = calogCreate();
if (first == NULL || second == NULL) {
CHECK(false, "per-runtime latch: runtime create failed");
calogDestroy(first);
calogDestroy(second);
return;
}
calogRegister(first, "mark", nativeMark, NULL);
calogRegister(second, "mark", nativeMark, NULL);
atomic_store(&markCount, 0);
calogValueNil(&result);
CHECK(calogAbortAll(first, &result) == calogErrAbortE, "calogAbortAll reports calogErrAbortE");
calogValueFree(&result);
CHECK(calogAborting(first), "the aborted runtime is latched");
CHECK(!calogAborting(second), "a second runtime is untouched");
calogValueNil(&result);
CHECK(calogCall(first, "mark", NULL, 0, &result) == calogErrAbortE, "the aborted runtime refuses a native");
calogValueFree(&result);
calogValueNil(&result);
CHECK(calogCall(second, "mark", NULL, 0, &result) == calogOkE, "the second runtime still serves natives");
calogValueFree(&result);
CHECK(atomic_load(&markCount) == 1, "exactly one of the two calls ran the native");
calogDestroy(first);
calogDestroy(second);
}
int main(void) {
// Each script: reach the host, abort, then a loop that must never be entered. The loop bodies
// differ only in each language's syntax for "forever".
static const EngineCaseT cases[] = {
{ &calogLuaEngine, "lua", "mark()\nstopAll()\nwhile true do end" },
{ &calogJsEngine, "js", "mark(); stopAll(); while (true) {}" },
{ &calogSquirrelEngine, "squirrel", "mark(); stopAll(); while(true){}" },
{ &calogMyBasicEngine, "my-basic", "mark()\nstopAll()\nWHILE 1\nWEND" },
{ &calogBerryEngine, "berry", "mark()\nstopAll()\nwhile true\nend" },
{ &calogS7Engine, "s7", "(begin (mark) (stopAll) (do () (#f)))" }, // s7 reads ONE top-level form
{ &calogWrenEngine, "wren", "Calog.call(\"mark\", [])\nCalog.call(\"stopAll\", [])\nwhile (true) {}" },
{ &calogMrubyEngine, "mruby", "mark()\nstopAll()\nwhile true do end" },
{ &calogTclEngine, "tcl", "mark\nstopAll\nwhile {1} {}" },
{ &calogJanetEngine, "janet", "(mark) (stopAll) (var i 0) (while true (set i (+ i 1)))" }
};
size_t index;
for (index = 0; index < sizeof(cases) / sizeof(cases[0]); index++) {
runCase(&cases[index]);
}
testLatchIsPerRuntime();
printf("testExit: %d checks, %d failed\n", testsRun, testsFailed);
return testsFailed == 0 ? 0 : 1;
}

View file

@ -8,6 +8,7 @@
#include "calog.h"
#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <time.h>
@ -24,9 +25,12 @@ static _Atomic int32_t shutdownCount = 0;
static _Atomic int32_t firstShutdownSeq = -1;
static _Atomic int32_t lastShutdownSeq = -1;
static _Atomic int64_t seenUserData = 0;
static _Atomic bool lateOpenSucceeded = false; // a context opened DURING teardown (must not happen)
static _Atomic bool lateEvalAccepted = false; // work queued onto a context that has stopped serving
static int32_t testsRun = 0;
static int32_t testsFailed = 0;
static int32_t userToken = 0x5A5A;
static CalogT *calog = NULL; // file scope: the shutdown hook reaches it too
static void checkImpl(bool condition, const char *message, const char *file, int32_t line);
static void contextInitHook(CalogContextT *context, void *userData);
@ -53,16 +57,32 @@ static void contextInitHook(CalogContextT *context, void *userData) {
static void contextShutdownHook(CalogContextT *context, void *userData) {
int32_t seq;
int32_t expected;
CalogContextT *late;
int32_t seq;
int32_t expected;
(void)context;
(void)userData;
seq = atomic_fetch_add(&seqCounter, 1);
atomic_fetch_add(&shutdownCount, 1);
expected = -1;
atomic_compare_exchange_strong(&firstShutdownSeq, &expected, seq);
atomic_store(&lastShutdownSeq, seq);
// This hook runs on the context's own thread while calogDestroy is walking the registry -- the
// one window where a script (taskSpawn) could still ask for a context the teardown has already
// walked past, then be freed with its thread running. Opening one has to be refused here.
(void)context;
late = calogContextOpen(calog, &calogLuaEngine);
if (late != NULL) {
atomic_store(&lateOpenSucceeded, true);
calogContextClose(late);
}
// Same window, the other half: this hook runs AFTER serveLoop stopped serving and BEFORE the
// interpreter is marked dead, so the context still resolves in the registry. Queueing work here
// used to be accepted and then never served -- a fire-and-forget eval silently dropped, and a
// blocking caller left waiting on a reply that could not come. It must be refused.
if (calogContextEval(context, "local dropped = 1") == calogOkE) {
atomic_store(&lateEvalAccepted, true);
}
}
@ -77,7 +97,6 @@ static void destroyBeforeHook(void) {
int main(void) {
CalogT *calog;
CalogContextT *ctxA;
CalogContextT *ctxB;
struct timespec ts = { 0, 500000 };
@ -110,6 +129,8 @@ int main(void) {
calogDestroy(calog); // before-hook -> tear down contexts (their shutdown hooks) -> after-hook
CHECK(atomic_load(&shutdownCount) == 2, "per-context shutdown hook fired once per context");
CHECK(!atomic_load(&lateOpenSucceeded), "opening a context during teardown is refused, not registered too late");
CHECK(!atomic_load(&lateEvalAccepted), "queueing work onto a context that has stopped serving is refused, not orphaned");
CHECK(atomic_load(&destroyBeforeSeq) >= 0, "before-context destroy hook fired");
CHECK(atomic_load(&destroyAfterSeq) >= 0, "after-context destroy hook fired");
CHECK(atomic_load(&destroyBeforeSeq) < atomic_load(&firstShutdownSeq), "before-hook ran before context teardown");

View file

@ -38,6 +38,7 @@ static void testTaskError(void);
static void testTaskLoad(void);
static void testTaskMutualClose(void);
static void testTaskSelfClose(void);
static void testTaskSpawnRacesTeardown(void);
static void testTaskSpawn(void);
static void writeFile(const char *path, const char *text);
@ -283,6 +284,54 @@ static void writeFile(const char *path, const char *text) {
}
// A script spawning tasks while the host tears the runtime down: calogDestroy walks the context
// registry, and a spawn landing in that window used to be able to register a context the walk had
// already passed -- freed, with its thread still running, along with the slot array the spawn had
// just reallocated under the walk's feet. calogContextOpen now refuses once teardown has latched, so
// the spawns simply start failing. Its own runtime, since it destroys it: surviving IS the check
// (under ASan a missed context is a use-after-free, under TSan a data race on the slot table).
static void testTaskSpawnRacesTeardown(void) {
CalogT *racer;
CalogContextT *ctx;
struct timespec ts = { 0, 2000000 };
int32_t i;
racer = calogCreate();
if (racer == NULL) {
CHECK(false, "spawn-vs-teardown: runtime create failed");
return;
}
calogSetErrorHandler(racer, onError, NULL);
if (calogTaskRegister(racer) != calogOkE) {
CHECK(false, "spawn-vs-teardown: task library register failed");
calogDestroy(racer);
return;
}
ctx = calogContextOpen(racer, &calogLuaEngine);
if (ctx == NULL) {
CHECK(false, "spawn-vs-teardown: context open failed");
calogDestroy(racer);
return;
}
// Spawn AND close in the same loop: taskClose reaches calogContextClose from this script's own
// thread, so it races the teardown's own stop-join-free of the very same context. Keep going
// until a spawn is refused (which is what teardown does to it) or the budget runs out; each
// child exits immediately, so the thread count stays bounded.
calogContextEval(ctx,
"for i = 1, 200 do\n"
" local ok, task = pcall(taskSpawn, 'lua', 'taskExit()')\n"
" if not ok then break end\n"
" pcall(taskClose, task)\n"
"end");
for (i = 0; i < 4; i++) {
calogPump(racer);
nanosleep(&ts, NULL);
}
calogDestroy(racer); // races the spawn loop above
CHECK(true, "a spawn loop racing calogDestroy tears down cleanly");
}
int main(void) {
calog = calogCreate();
if (calog == NULL) {
@ -309,6 +358,7 @@ int main(void) {
testTaskError();
calogDestroy(calog);
testTaskSpawnRacesTeardown();
printf("\n%d checks, %d failed\n", testsRun, testsFailed);
fflush(stdout);

347
tests/testTeardown.c Normal file
View file

@ -0,0 +1,347 @@
// testTeardown.c -- a library registry that holds a SCRIPT function must let go of it while the
// owning context is still alive.
//
// A CalogFnT is a handle to a function living inside a VM. If a process-global registry (a pubsub
// subscriber, an exported function, a timer callback) still holds one when that context's
// interpreter is destroyed, the release arrives too late to run the engine's own release hook and
// the script object is never freed inside its VM. That is what the two destroy phases are for: a
// library holding context-owned references registers calogDestroyBeforeContextsE, so calogDestroy
// drains it while every context is still serving its queue.
//
// Most VMs hide the mistake -- they free everything on close -- so this test uses JavaScript on
// purpose: QuickJS asserts it owns no live objects at JS_FreeRuntime and ABORTS the process. Every
// case here therefore checks the same thing in the end: that we are still running afterwards. A
// wrong phase does not fail a check, it kills the binary (and LeakSanitizer catches the milder
// variant, a registry resurrected after its shutdown).
#define _POSIX_C_SOURCE 200809L
#include "calog.h"
#include "calogExport.h"
#include "calogPubsub.h"
#include "calogTimer.h"
#include "calogInternal.h" // calogPubsubShutdown/calogExportShutdown: internal, driven by hand here
#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <time.h>
#define CHECK(cond, msg) checkImpl((cond), (msg), __LINE__)
#define PUMP_INTERVAL_NS 500000
#define PUMP_LIMIT 4000
static CalogT *calog = NULL;
static _Atomic bool readyFlag = false;
static _Atomic int32_t reportValue = -1;
static _Atomic int32_t errorCount = 0;
static int32_t testsRun = 0;
static int32_t testsFailed = 0;
static void checkImpl(bool condition, const char *message, int32_t line);
static int32_t nativeEndSelf(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static int32_t nativeReady(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static int32_t nativeReport(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static void onError(uint64_t contextId, const char *message, void *userData);
static void pumpUntilReady(void);
static void startRuntime(void);
static void testCrossEngineValueOutlivesOwner(void);
static void testGuardsAfterShutdown(void);
static void testHeldCallableSurvivesTeardown(const char *what, const char *source);
static void testOwnerDiesBeforeTheRuntime(const char *what, const char *source, bool expectError);
static void checkImpl(bool condition, const char *message, int32_t line) {
testsRun++;
if (!condition) {
testsFailed++;
printf("FAIL testTeardown.c:%d %s\n", line, message);
}
}
// What the runner's calogEnd does: end THIS script, leaving the runtime and every other script
// running. Inline, so it runs on the calling script's own thread and can unwind it.
static int32_t nativeEndSelf(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
(void)args;
(void)argCount;
(void)userData;
calogValueNil(result);
return calogAbortCurrent(result);
}
static int32_t nativeReady(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
(void)args;
(void)argCount;
(void)userData;
atomic_store(&readyFlag, true);
calogValueNil(result);
return calogOkE;
}
static int32_t nativeReport(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
(void)userData;
calogValueNil(result);
if (argCount == 1 && args[0].type == calogBoolE) {
atomic_store(&reportValue, args[0].as.b ? 1 : 0);
}
return calogOkE;
}
static void onError(uint64_t contextId, const char *message, void *userData) {
(void)contextId;
(void)userData;
atomic_fetch_add(&errorCount, 1);
printf(" (error handler saw: %s)\n", message != NULL ? message : "(null)");
}
static void pumpUntilReady(void) {
struct timespec tick = { 0, PUMP_INTERVAL_NS };
int32_t index;
for (index = 0; index < PUMP_LIMIT; index++) {
calogPump(calog);
if (atomic_load(&readyFlag)) {
calogPump(calog);
return;
}
nanosleep(&tick, NULL);
}
}
// A runtime with the three libraries that hold script functions, plus the two natives a script
// signals through.
static void startRuntime(void) {
calog = calogCreate();
if (calog == NULL) {
CHECK(false, "runtime create failed");
return;
}
calogSetErrorHandler(calog, onError, NULL);
calogRegisterInline(calog, "ready", nativeReady, NULL);
calogRegisterInline(calog, "report", nativeReport, NULL);
calogRegisterInline(calog, "endSelf", nativeEndSelf, NULL);
calogPubsubRegister(calog);
calogExportRegister(calog);
calogTimerRegister(calog);
atomic_store(&readyFlag, false);
atomic_store(&reportValue, -1);
atomic_store(&errorCount, 0);
}
// Once a library's registry has been drained, a native that would store a NEW script function in it
// has to fail: re-growing a registry that nothing will ever free again would strand the callable it
// holds (the very leak the phase fix exists to prevent). Reads stay safe, which is what the pubsub
// and export tests already rely on.
static void testGuardsAfterShutdown(void) {
CalogContextT *ctx;
startRuntime();
if (calog == NULL) {
return;
}
ctx = calogContextOpen(calog, &calogLuaEngine);
if (ctx == NULL) {
CHECK(false, "guards: context open failed");
calogDestroy(calog);
return;
}
calogPubsubShutdown();
calogExportShutdown();
// pcall yields (ok, err), so bind ok first -- report takes exactly one value.
calogContextEval(ctx, "local ok = pcall(psSubscribe, 't', function() end)\n report(ok)\n ready()");
pumpUntilReady();
CHECK(atomic_load(&reportValue) == 0, "psSubscribe after the pubsub registry is drained fails cleanly");
atomic_store(&readyFlag, false);
atomic_store(&reportValue, -1);
calogContextEval(ctx, "local ok = pcall(calogExport, 'e', function() end)\n report(ok)\n ready()");
pumpUntilReady();
CHECK(atomic_load(&reportValue) == 0, "calogExport after the export registry is drained fails cleanly");
// Reads stay safe either way: a publish simply finds nobody, and an unknown global still
// resolves to nil through the export hook.
atomic_store(&readyFlag, false);
atomic_store(&reportValue, -1);
calogContextEval(ctx, "report(psPublish('t', 1) == 0 and someUndefinedGlobalName == nil)\n ready()");
pumpUntilReady();
CHECK(atomic_load(&reportValue) == 1, "publishing and resolving after shutdown stay safe");
calogContextClose(ctx);
calogDestroy(calog);
}
// Run source on a JavaScript context, leave whatever it registered in place, and tear the runtime
// down with calogDestroy alone -- the path a real host takes. Surviving IS the check.
static void testHeldCallableSurvivesTeardown(const char *what, const char *source) {
CalogContextT *ctx;
printf(" teardown case: %s\n", what);
fflush(stdout);
startRuntime();
if (calog == NULL) {
return;
}
ctx = calogContextOpen(calog, &calogJsEngine);
if (ctx == NULL) {
checkImpl(false, what, __LINE__);
calogDestroy(calog);
return;
}
calogContextEval(ctx, source);
pumpUntilReady();
checkImpl(atomic_load(&readyFlag), what, __LINE__);
checkImpl(atomic_load(&errorCount) == 0, "the script registered its callbacks without error", __LINE__);
// No calogContextClose and no by-hand library shutdown: calogDestroy must get the order right
// on its own, which is exactly what the destroy-hook phases are for.
calogDestroy(calog);
checkImpl(true, what, __LINE__);
}
// The other half of the rule, and the one the destroy phases cannot reach: a context that dies while
// the RUNTIME lives on -- a script that errors out, or one that ends itself with calogEnd -- has to
// reclaim its handles just the same. Its interpreter is destroyed on its own thread the moment it
// stops, long before anyone tears the runtime down.
static void testOwnerDiesBeforeTheRuntime(const char *what, const char *source, bool expectError) {
CalogContextT *ctx;
struct timespec tick = { 0, PUMP_INTERVAL_NS };
int32_t index;
bool finished;
printf(" early-death case: %s\n", what);
fflush(stdout);
startRuntime();
if (calog == NULL) {
return;
}
ctx = calogContextOpen(calog, &calogJsEngine);
if (ctx == NULL) {
checkImpl(false, what, __LINE__);
calogDestroy(calog);
return;
}
calogContextEval(ctx, source);
// Wait for the context's own thread to exit: an errored script is retired by the test's error
// handler doing nothing at all -- the context ends because the script ended it (endSelf) or
// because we close it below.
finished = false;
for (index = 0; index < PUMP_LIMIT; index++) {
calogPump(calog);
if (calogContextFinished(ctx)) {
finished = true;
break;
}
if (expectError && atomic_load(&errorCount) > 0) {
break;
}
nanosleep(&tick, NULL);
}
if (expectError) {
checkImpl(atomic_load(&errorCount) > 0, "the script's failure was reported", __LINE__);
} else {
checkImpl(finished, "endSelf ended the script, so its context can be reaped", __LINE__);
checkImpl(atomic_load(&errorCount) == 0, "ending a script is not reported as a failure", __LINE__);
}
// Closing joins the thread, which is where the interpreter is destroyed -- the moment a handle
// that outlived its VM would take the process down.
calogContextClose(ctx);
calogPump(calog);
checkImpl(true, what, __LINE__);
calogDestroy(calog);
}
// A function value that crossed engines: JavaScript hands its own closure to a Lua script, which
// keeps it, and then the JavaScript context dies. No library registry holds the closure -- the other
// VM does -- so only reclaiming on the owner's own thread can save it.
static void testCrossEngineValueOutlivesOwner(void) {
CalogContextT *holder;
CalogContextT *giver;
struct timespec tick = { 0, PUMP_INTERVAL_NS };
int32_t index;
printf(" early-death case: a JS closure kept by a Lua script when JS dies\n");
fflush(stdout);
startRuntime();
if (calog == NULL) {
return;
}
holder = calogContextOpen(calog, &calogLuaEngine);
giver = calogContextOpen(calog, &calogJsEngine);
if (holder == NULL || giver == NULL) {
checkImpl(false, "cross-engine: context open failed", __LINE__);
calogDestroy(calog);
return;
}
calogContextEval(holder, "held = nil\n calogExport('keep', function(fn) held = fn return 1 end)\n ready()");
pumpUntilReady();
checkImpl(atomic_load(&readyFlag), "the Lua holder published its keep() export", __LINE__);
atomic_store(&readyFlag, false);
calogContextEval(giver, "calogCall('keep', function () { return 42; }); ready(); endSelf();");
pumpUntilReady();
for (index = 0; index < PUMP_LIMIT; index++) {
calogPump(calog);
if (calogContextFinished(giver)) {
break;
}
nanosleep(&tick, NULL);
}
checkImpl(calogContextFinished(giver), "the giving context ended itself", __LINE__);
calogContextClose(giver); // joins: the JS interpreter is destroyed here
calogPump(calog);
checkImpl(atomic_load(&errorCount) == 0, "no error while the JS owner went away", __LINE__);
// The Lua script still holds the (now dead) closure. Invoking it must fail cleanly rather than
// reach into the destroyed VM, and dropping it must not double-free anything.
atomic_store(&readyFlag, false);
atomic_store(&reportValue, -1);
calogContextEval(holder, "local ok = pcall(held)\n report(ok)\n held = nil\n ready()");
pumpUntilReady();
checkImpl(atomic_load(&reportValue) == 0, "invoking a callable whose owner is gone fails cleanly", __LINE__);
calogContextClose(holder);
calogDestroy(calog);
}
int main(void) {
testHeldCallableSurvivesTeardown("a JS subscriber still registered at teardown",
"psSubscribe('t', function () { return 1; }); ready();");
testHeldCallableSurvivesTeardown("a JS export still registered at teardown",
"calogExport('e', function () { return 1; }); ready();");
testHeldCallableSurvivesTeardown("a JS timer callback still armed at teardown",
"timerEvery(1000, function () {}); ready();");
testHeldCallableSurvivesTeardown("all three at once, several callbacks each",
"psSubscribe('a', function () {}); psSubscribe('b', function () {});"
"calogExport('x', function () {}); calogExport('y', function () {});"
"timerEvery(1000, function () {}); timerAfter(1000, function () {});"
"ready();");
testOwnerDiesBeforeTheRuntime("a JS subscriber whose script then errors out",
"psSubscribe('t', function () {}); ready(); throw new Error('boom');", true);
testOwnerDiesBeforeTheRuntime("a JS export whose script then errors out",
"calogExport('e', function () {}); ready(); throw new Error('boom');", true);
testOwnerDiesBeforeTheRuntime("a JS timer callback whose script then errors out",
"timerEvery(1000, function () {}); ready(); throw new Error('boom');", true);
testOwnerDiesBeforeTheRuntime("a JS subscriber whose script ends itself (calogEnd)",
"psSubscribe('t', function () {}); ready(); endSelf();", false);
testCrossEngineValueOutlivesOwner();
testGuardsAfterShutdown();
printf("\n%d checks, %d failed\n", testsRun, testsFailed);
fflush(stdout);
return testsFailed == 0 ? 0 : 1;
}

View file

@ -32,10 +32,32 @@ LUASRC=$(ls vendor/lua/src/*.c | grep -vE '/(lua|luac)\.c$' | tr '\n' ' ')
ENET_UNIX=$(ls vendor/enet/*.c | grep -vE '/win32\.c$' | tr '\n' ' ')
ENET_WIN=$(ls vendor/enet/*.c | grep -vE '/unix\.c$' | tr '\n' ' ')
SQSRC=$(ls vendor/squirrel-src/squirrel/*.cpp | tr '\n' ' ')
BERRYSRC=$(ls vendor/berry/src/*.c vendor/berry/default/be_port.c vendor/berry/default/be_modtab.c | tr '\n' ' ')
JANETDEF="-DJANET_NO_NET -DJANET_NO_PROCESSES -DJANET_NO_DYNAMIC_MODULES"
MBSRC=$(ls vendor/ourbasic/*.c | tr '\n' ' ')
ENETDEF="-D_GNU_SOURCE -DHAS_FCNTL=1 -DHAS_POLL=1 -DHAS_GETADDRINFO=1 -DHAS_GETNAMEINFO=1 -DHAS_GETHOSTBYNAME_R=1 -DHAS_GETHOSTBYADDR_R=1 -DHAS_INET_PTON=1 -DHAS_INET_NTOP=1 -DHAS_MSGHDR_FLAGS=1 -DHAS_SOCKLEN_T=1"
pass=0; fail=0
pass=0; fail=0; skip=0
# Per-target OpenSSL, built by `tools/crossDeps.sh <target> openssl`. calogNet has needed it since
# the tcp transport gained TLS, which is what this script's testNet case links against. Echoes the
# include + archive flags for <target>, or nothing when that target's OpenSSL has not been built --
# the caller then SKIPS testNet rather than reporting a failure the script cannot fix itself.
# mac wants the repacked BSD-format archives; zig's Mach-O linker cannot read OpenSSL's GNU-format .a.
ossl_flags() { # <target: musl|win|mac-x64|mac-arm64>
local dir="$ROOT/build/cross/$1"
local libs="$dir/openssl-src"
case "$1" in
mac-x64|mac-arm64) libs="$dir/openssl-repack" ;;
esac
[ -f "$libs/libssl.a" ] && [ -f "$libs/libcrypto.a" ] || return 1
echo "-I$dir/openssl-src/include $libs/libssl.a $libs/libcrypto.a"
}
skip_no_ossl() { # <name> <target>
echo " [$2 SKIP] $1 -- no OpenSSL for this target; run: ./tools/crossDeps.sh $2 openssl"
skip=$((skip+1))
}
mrun() { # name, then compiler args -- build static musl, then RUN it
local name=$1; shift
if "$@" -o "$OUT/$name-musl" 2>"$OUT/$name-musl.err"; then
@ -48,6 +70,22 @@ mrun() { # name, then compiler args -- build static musl, then RUN it
echo " [musl FAIL] $name (see $OUT/$name-musl.err)"; fail=$((fail+1))
fi
}
# Like mrun, but the binary MUST exit 0, and its last line is echoed. For tests whose whole point is
# the RUNTIME result: mrun treats a nonzero exit as a pass ("RAN, nonzero"), which would report a
# broken TLS handshake as success. timeout bounds the one hang shape a server test has.
mrunStrict() { # name, then compiler args -- build static musl, then RUN it; nonzero == FAIL
local name=$1; shift
if "$@" -o "$OUT/$name-musl" 2>"$OUT/$name-musl.err"; then
if timeout 120 "$OUT/$name-musl" >"$OUT/$name-musl.out" 2>&1; then
echo " [musl RUN ok] $name -- $(tail -n 1 "$OUT/$name-musl.out")"; pass=$((pass+1))
else
echo " [musl RUN FAILED] $name (see $OUT/$name-musl.out)"; fail=$((fail+1))
fi
else
echo " [musl FAIL] $name (see $OUT/$name-musl.err)"; fail=$((fail+1))
fi
}
wbuild() { # name, then compiler args -- build a Windows .exe, verify it is a PE
local name=$1; shift
if "$@" -o "$OUT/$name.exe" 2>"$OUT/$name.exe.err"; then
@ -83,11 +121,74 @@ done
WPA="$OUT/libwinpthreads.a"
WPINC="-Ivendor/winpthreads/include -DWINPTHREAD_STATIC=1"
# s7 on Windows: s7.c takes its POSIX path (it only sets MS_Windows from _MSC_VER, which clang/mingw
# never defines) and includes <sys/utsname.h>, which mingw-w64 does not ship. Generate the same
# minimal shim the full-CLI build uses, rather than depend on that one -- it lives under build/,
# which is untracked, so it cannot be assumed present.
mkdir -p "$OUT/shim/sys"
cat > "$OUT/shim/sys/utsname.h" <<'UTSNAME'
// utsname.h -- cross-build compatibility shim for vendored s7 on mingw-w64 (generated by
// tools/crossBuild.sh). s7.c includes <sys/utsname.h> under `#if !MS_Windows`, and it only defines
// MS_Windows from _MSC_VER, so the clang/mingw cross build wrongly takes the POSIX path. This
// satisfies the include without patching the pristine vendored source; it is on s7's include path
// only, so nothing else in calog sees it.
#ifndef CALOG_SHIM_SYS_UTSNAME_H
#define CALOG_SHIM_SYS_UTSNAME_H
#include <string.h>
struct utsname {
char sysname[65];
char nodename[65];
char release[65];
char version[65];
char machine[65];
};
static inline int uname(struct utsname *u) {
if (u == NULL) {
return -1;
}
memset(u, 0, sizeof(*u));
strcpy(u->sysname, "Windows");
strcpy(u->nodename, "localhost");
strcpy(u->release, "10");
strcpy(u->version, "10");
strcpy(u->machine, "x86_64");
return 0;
}
#endif
UTSNAME
S7SHIM="-I$OUT/shim"
echo "== musl (fully static Linux, built AND run) =="
mrun testEngineLua "$ZIG" cc -target x86_64-linux-musl -static -O2 -pthread -w -Isrc -Isrc/lua -Ilibs -Ivendor/lua/src -DLUA_USE_POSIX $CORE src/lua/luaEngine.c src/lua/luaAdapter.c tests/testEngineLua.c $LUASRC -lm
mrun testEngineJs "$ZIG" cc -target x86_64-linux-musl -static -O2 -pthread -w -Isrc -Isrc/js -Ilibs -Ivendor/quickjs -D_GNU_SOURCE $CORE src/js/jsEngine.c src/js/jsAdapter.c tests/testEngineJs.c vendor/quickjs/quickjs.c vendor/quickjs/libregexp.c vendor/quickjs/libunicode.c vendor/quickjs/dtoa.c -lm
mrun testEngineMyBasic "$ZIG" cc -target x86_64-linux-musl -static -O2 -pthread -w -Isrc -Isrc/mybasic -Ilibs -Ivendor/ourbasic -DMB_DOUBLE_FLOAT $CORE src/mybasic/mybasicEngine.c src/mybasic/mybasicAdapter.c tests/testEngineMyBasic.c $MBSRC -lm
mrun testNet "$ZIG" cc -target x86_64-linux-musl -static -O2 -pthread -w -Isrc -Isrc/lua -Ilibs -Ivendor/lua/src -Ivendor/enet/include -DLUA_USE_POSIX $ENETDEF $CORE src/lua/luaEngine.c src/lua/luaAdapter.c libs/calogNet.c libs/calogHandle.c tests/testNet.c $LUASRC $ENET_UNIX -lm
if OSSL=$(ossl_flags musl); then
mrun testNet "$ZIG" cc -target x86_64-linux-musl -static -O2 -pthread -w -Isrc -Isrc/lua -Ilibs -Ivendor/lua/src -Ivendor/enet/include -DLUA_USE_POSIX $ENETDEF $CORE src/lua/luaEngine.c src/lua/luaAdapter.c libs/calogNet.c libs/calogHandle.c tests/testNet.c $LUASRC $ENET_UNIX $OSSL -lm
# Real TLS at runtime, so the cross-built OpenSSL is EXERCISED rather than merely link-verified:
# testHttps generates an RSA key + self-signed cert in-process and drives a loopback TLS server.
# No external server and no egress. musl only -- testHttps uses POSIX sockets/pthreads/mkstemp,
# which x86_64-windows-gnu cannot compile, and a mac build would add nothing over testNet's link.
mrunStrict testHttps "$ZIG" cc -target x86_64-linux-musl -static -O2 -pthread -w -Isrc -Isrc/lua -Ilibs -Ivendor/lua/src -DLUA_USE_POSIX $CORE src/lua/luaEngine.c src/lua/luaAdapter.c libs/calogHttp.c tests/testHttps.c $LUASRC $OSSL -lm
else
skip_no_ossl testNet musl
skip_no_ossl testHttps musl
fi
mrun testEngineBerry "$ZIG" cc -target x86_64-linux-musl -static -O2 -pthread -w -Isrc -Isrc/berry -Ilibs -Ivendor/berry/src -Ivendor/berry $CORE src/berry/berryEngine.c src/berry/berryAdapter.c tests/testEngineBerry.c $BERRYSRC -lm
mrun testEngineS7 "$ZIG" cc -target x86_64-linux-musl -static -O2 -pthread -w -Isrc -Isrc/s7 -Ilibs -Ivendor/s7 -D_GNU_SOURCE $CORE src/s7/s7Engine.c src/s7/s7Adapter.c tests/testEngineS7.c vendor/s7/s7.c -lm
mrun testEngineWren "$ZIG" cc -target x86_64-linux-musl -static -O2 -pthread -w -Isrc -Isrc/wren -Ilibs -Ivendor/wren $CORE src/wren/wrenEngine.c src/wren/wrenAdapter.c tests/testEngineWren.c vendor/wren/wren.c -lm
mrun testEngineJanet "$ZIG" cc -target x86_64-linux-musl -static -O2 -pthread -w -Isrc -Isrc/janet -Ilibs -Ivendor/janet $JANETDEF $CORE src/janet/janetEngine.c src/janet/janetAdapter.c tests/testEngineJanet.c vendor/janet/janet.c -lm
# mruby and Tcl are the two engines that need a per-target prebuilt archive rather than vendored C:
# tools/crossDeps.sh <target> mruby / tcl produces them. Skip cleanly when they are not there.
if [ -f "$ROOT/build/cross/musl/mruby/libmruby.a" ]; then
mrun testEngineMruby "$ZIG" cc -target x86_64-linux-musl -static -O2 -pthread -w -Isrc -Isrc/mruby -Ilibs -Ivendor/mruby/include -Ivendor/mruby/build/cross-musl/include -DMRB_USE_DEBUG_HOOK $CORE src/mruby/mrubyEngine.c src/mruby/mrubyAdapter.c tests/testEngineMruby.c "$ROOT/build/cross/musl/mruby/libmruby.a" -lm
else
echo " [musl SKIP] testEngineMruby -- run: ./tools/crossDeps.sh musl mruby"; skip=$((skip+1))
fi
if [ -f "$ROOT/build/cross/musl/tcl/libtcl9.0.a" ]; then
mrun testEngineTcl "$ZIG" cc -target x86_64-linux-musl -static -O2 -pthread -w -Isrc -Isrc/tcl -Ilibs -Ivendor/tcl/generic -Ivendor/tcl/unix $CORE src/tcl/tclEngine.c src/tcl/tclAdapter.c tests/testEngineTcl.c "$ROOT/build/cross/musl/tcl/libtcl9.0.a" "$ROOT/build/cross/musl/lib/libz.a" -lm
else
echo " [musl SKIP] testEngineTcl -- run: ./tools/crossDeps.sh musl tcl"; skip=$((skip+1))
fi
# Squirrel is C++ (vendored VM); the calog .c files stay C (-x c), the VM is C++ (-x c++).
if "$ZIG" c++ -target x86_64-linux-musl -static -O2 -pthread -w -Isrc -Isrc/squirrel -Ilibs -Ivendor/squirrel-src/include -Ivendor/squirrel-src/squirrel -D_SQ64 -DSQUSEDOUBLE \
-x c $CORE src/squirrel/squirrelEngine.c src/squirrel/squirrelAdapter.c tests/testEngineSquirrel.c -x c++ $SQSRC -o "$OUT/testEngineSquirrel-musl" 2>"$OUT/sq-musl.err"; then
@ -96,14 +197,70 @@ else echo " [musl FAIL] testEngineSquirrel"; fail=$((fail+1)); fi
echo "== Windows x64 (.exe built against vendored winpthreads; run needs wine) =="
wbuild testEngineLua "$ZIG" cc -target x86_64-windows-gnu -O2 -w -Isrc -Isrc/lua -Ilibs -Ivendor/lua/src $WPINC $CORE src/lua/luaEngine.c src/lua/luaAdapter.c tests/testEngineLua.c $LUASRC "$WPA"
wbuild testNet "$ZIG" cc -target x86_64-windows-gnu -O2 -w -Isrc -Isrc/lua -Ilibs -Ivendor/lua/src -Ivendor/enet/include $WPINC $CORE src/lua/luaEngine.c src/lua/luaAdapter.c libs/calogNet.c libs/calogHandle.c tests/testNet.c $LUASRC $ENET_WIN "$WPA" -lws2_32 -lwinmm
wbuild testEngineBerry "$ZIG" cc -target x86_64-windows-gnu -O2 -w -Isrc -Isrc/berry -Ilibs -Ivendor/berry/src -Ivendor/berry $WPINC $CORE src/berry/berryEngine.c src/berry/berryAdapter.c tests/testEngineBerry.c $BERRYSRC "$WPA"
wbuild testEngineS7 "$ZIG" cc -target x86_64-windows-gnu -O2 -w -Isrc -Isrc/s7 -Ilibs -Ivendor/s7 $S7SHIM -D_GNU_SOURCE $WPINC $CORE src/s7/s7Engine.c src/s7/s7Adapter.c tests/testEngineS7.c vendor/s7/s7.c "$WPA"
wbuild testEngineWren "$ZIG" cc -target x86_64-windows-gnu -O2 -w -Isrc -Isrc/wren -Ilibs -Ivendor/wren $WPINC $CORE src/wren/wrenEngine.c src/wren/wrenAdapter.c tests/testEngineWren.c vendor/wren/wren.c "$WPA"
wbuild testEngineJanet "$ZIG" cc -target x86_64-windows-gnu -O2 -w -Isrc -Isrc/janet -Ilibs -Ivendor/janet $JANETDEF $WPINC $CORE src/janet/janetEngine.c src/janet/janetAdapter.c tests/testEngineJanet.c vendor/janet/janet.c "$WPA"
if [ -f "$ROOT/build/cross/win/tcl/libtcl90.a" ]; then
wbuild testEngineTcl "$ZIG" cc -target x86_64-windows-gnu -O2 -w -Isrc -Isrc/tcl -Ilibs -Ivendor/tcl/generic -Ivendor/tcl/win -DSTATIC_BUILD $WPINC $CORE src/tcl/tclEngine.c src/tcl/tclAdapter.c tests/testEngineTcl.c "$ROOT/build/cross/win/tcl/libtcl90.a" "$WPA" -lws2_32 -lnetapi32 -luserenv -lole32 -loleaut32 -luuid
else
echo " [win SKIP] testEngineTcl -- run: ./tools/crossDeps.sh win tcl"; skip=$((skip+1))
fi
if [ -f "$ROOT/build/cross/win/mruby/libmruby.a" ]; then
wbuild testEngineMruby "$ZIG" cc -target x86_64-windows-gnu -O2 -w -Isrc -Isrc/mruby -Ilibs -Ivendor/mruby/include -Ivendor/mruby/build/cross-mingw/include -DMRB_USE_DEBUG_HOOK $WPINC $CORE src/mruby/mrubyEngine.c src/mruby/mrubyAdapter.c tests/testEngineMruby.c "$ROOT/build/cross/win/mruby/libmruby.a" "$WPA"
else
echo " [win SKIP] testEngineMruby -- run: ./tools/crossDeps.sh win mruby"; skip=$((skip+1))
fi
if OSSL=$(ossl_flags win); then
wbuild testNet "$ZIG" cc -target x86_64-windows-gnu -O2 -w -Isrc -Isrc/lua -Ilibs -Ivendor/lua/src -Ivendor/enet/include $WPINC $CORE src/lua/luaEngine.c src/lua/luaAdapter.c libs/calogNet.c libs/calogHandle.c tests/testNet.c $LUASRC $ENET_WIN "$WPA" $OSSL -lws2_32 -lwinmm -lcrypt32 -lbcrypt -ladvapi32 -luser32
else
skip_no_ossl testNet win
fi
# Squirrel: C++ VM, so the C sources are forced with -x c and the VM with -x c++, as on musl.
if "$ZIG" c++ -target x86_64-windows-gnu -O2 -w -Isrc -Isrc/squirrel -Ilibs -Ivendor/squirrel-src/include -Ivendor/squirrel-src/squirrel -D_SQ64 -DSQUSEDOUBLE $WPINC \
-x c $CORE src/squirrel/squirrelEngine.c src/squirrel/squirrelAdapter.c tests/testEngineSquirrel.c -x c++ $SQSRC -x none "$WPA" -o "$OUT/testEngineSquirrel.exe" 2>"$OUT/sq-win.err"; then
if file "$OUT/testEngineSquirrel.exe" | grep -q 'PE32+ executable'; then
echo " [win BUILT] testEngineSquirrel.exe"; pass=$((pass+1))
else echo " [win ??] testEngineSquirrel.exe (not a PE?)"; fail=$((fail+1)); fi
else echo " [win FAIL] testEngineSquirrel.exe (see $OUT/sq-win.err)"; fail=$((fail+1)); fi
echo "== macOS (Mach-O built via zig's libSystem stub -- Intel + Apple Silicon; run needs a Mac) =="
for arch in x86_64 aarch64; do
mbuild "$arch" testEngineLua "$ZIG" cc -target $arch-macos -O2 -w -Isrc -Isrc/lua -Ilibs -Ivendor/lua/src -DLUA_USE_MACOSX $CORE src/lua/luaEngine.c src/lua/luaAdapter.c tests/testEngineLua.c $LUASRC
mbuild "$arch" testNet "$ZIG" cc -target $arch-macos -O2 -w -Isrc -Isrc/lua -Ilibs -Ivendor/lua/src -Ivendor/enet/include -DLUA_USE_MACOSX $ENETDEF $CORE src/lua/luaEngine.c src/lua/luaAdapter.c libs/calogNet.c libs/calogHandle.c tests/testNet.c $LUASRC $ENET_UNIX
mbuild "$arch" testEngineBerry "$ZIG" cc -target $arch-macos -O2 -w -Isrc -Isrc/berry -Ilibs -Ivendor/berry/src -Ivendor/berry $CORE src/berry/berryEngine.c src/berry/berryAdapter.c tests/testEngineBerry.c $BERRYSRC
mbuild "$arch" testEngineS7 "$ZIG" cc -target $arch-macos -O2 -w -Isrc -Isrc/s7 -Ilibs -Ivendor/s7 -D_GNU_SOURCE $CORE src/s7/s7Engine.c src/s7/s7Adapter.c tests/testEngineS7.c vendor/s7/s7.c
mbuild "$arch" testEngineWren "$ZIG" cc -target $arch-macos -O2 -w -Isrc -Isrc/wren -Ilibs -Ivendor/wren $CORE src/wren/wrenEngine.c src/wren/wrenAdapter.c tests/testEngineWren.c vendor/wren/wren.c
mbuild "$arch" testEngineJanet "$ZIG" cc -target $arch-macos -O2 -w -Isrc -Isrc/janet -Ilibs -Ivendor/janet $JANETDEF $CORE src/janet/janetEngine.c src/janet/janetAdapter.c tests/testEngineJanet.c vendor/janet/janet.c
MACT=$(echo "$arch" | sed 's/^x86_64$/mac-x64/; s/^aarch64$/mac-arm64/')
if [ -f "$ROOT/build/cross/$MACT/mruby/libmruby.a" ]; then
mbuild "$arch" testEngineMruby "$ZIG" cc -target $arch-macos -O2 -w -Isrc -Isrc/mruby -Ilibs -Ivendor/mruby/include -Ivendor/mruby/build/cross-$MACT/include -DMRB_USE_DEBUG_HOOK $CORE src/mruby/mrubyEngine.c src/mruby/mrubyAdapter.c tests/testEngineMruby.c "$ROOT/build/cross/$MACT/mruby/libmruby.a"
else
echo " [mac SKIP] testEngineMruby ($arch) -- run: ./tools/crossDeps.sh $MACT mruby"; skip=$((skip+1))
fi
# Tcl links zlib; there is no system libz for a cross target, so use the one build_codecs made.
if [ -f "$ROOT/build/cross/$MACT/tcl/libtcl9.0.a" ] && [ -f "$ROOT/build/cross/$MACT/lib/libz.a" ]; then
mbuild "$arch" testEngineTcl "$ZIG" cc -target $arch-macos -O2 -w -Isrc -Isrc/tcl -Ilibs -Ivendor/tcl/generic -Ivendor/tcl/unix $CORE src/tcl/tclEngine.c src/tcl/tclAdapter.c tests/testEngineTcl.c "$ROOT/build/cross/$MACT/tcl/libtcl9.0.a" "$ROOT/build/cross/$MACT/lib/libz.a"
else
echo " [mac SKIP] testEngineTcl ($arch) -- run: ./tools/crossDeps.sh $MACT tcl codecs"; skip=$((skip+1))
fi
if "$ZIG" c++ -target $arch-macos -O2 -w -Isrc -Isrc/squirrel -Ilibs -Ivendor/squirrel-src/include -Ivendor/squirrel-src/squirrel -D_SQ64 -DSQUSEDOUBLE \
-x c $CORE src/squirrel/squirrelEngine.c src/squirrel/squirrelAdapter.c tests/testEngineSquirrel.c -x c++ $SQSRC -o "$OUT/testEngineSquirrel-$arch-macos" 2>"$OUT/sq-$arch-macos.err"; then
if file "$OUT/testEngineSquirrel-$arch-macos" | grep -q 'Mach-O'; then
echo " [mac BUILT] testEngineSquirrel ($arch)"; pass=$((pass+1))
else echo " [mac ??] testEngineSquirrel ($arch, not Mach-O?)"; fail=$((fail+1)); fi
else echo " [mac FAIL] testEngineSquirrel ($arch, see $OUT/sq-$arch-macos.err)"; fail=$((fail+1)); fi
if OSSL=$(ossl_flags "$MACT"); then
mbuild "$arch" testNet "$ZIG" cc -target $arch-macos -O2 -w -Isrc -Isrc/lua -Ilibs -Ivendor/lua/src -Ivendor/enet/include -DLUA_USE_MACOSX $ENETDEF $CORE src/lua/luaEngine.c src/lua/luaAdapter.c libs/calogNet.c libs/calogHandle.c tests/testNet.c $LUASRC $ENET_UNIX $OSSL
else
skip_no_ossl testNet "$arch"
fi
done
echo
echo "== cross-build: $pass ok, $fail failed (artifacts in $OUT/) =="
if [ "$skip" -gt 0 ]; then
echo "== cross-build: $pass ok, $fail failed, $skip skipped (artifacts in $OUT/) =="
else
echo "== cross-build: $pass ok, $fail failed (artifacts in $OUT/) =="
fi
[ "$fail" -eq 0 ]

View file

@ -7,10 +7,16 @@
# winpthreads). It regenerates the zig compiler wrappers + the CMake toolchain file first, so nothing
# under build/cross/ needs to pre-exist.
#
# Usage: [ZIG=/path/to/zig] ./tools/crossDeps.sh <win|mac-x64|mac-arm64> [dep ...]
# Usage: [ZIG=/path/to/zig] ./tools/crossDeps.sh <win|mac-x64|mac-arm64|musl> [dep ...]
# dep (optional): build only the named dep(s), e.g. `openssl libssh2`. Default: all, in dep order.
# Deps in order: codecs xz openssl libxml2 pcre2 libssh2 mariadb postgres tcl mruby winpthreads libarchive
#
# musl builds every dep except winpthreads, which is Windows-only by definition and is dropped
# from the default list for that target. It exists so tools/crossBuild.sh can cross-test the whole
# engine matrix statically (tcl and mruby need per-target archives; calogNet needs OpenSSL since
# the tcp transport gained TLS). Note this is the CROSS route from a glibc host; the other musl
# path is `make static` in an Alpine container, where the native toolchain is already musl.
#
# Requirements: zig (https://ziglang.org/download/), cmake, perl (OpenSSL), and a POSIX make/rake env.
set -eu
cd "$(dirname "$0")/.."
@ -27,7 +33,8 @@ case "$T" in
win) TRIPLE=x86_64-windows-gnu; SYSNAME=Windows; SYSPROC=x86_64 ;;
mac-x64) TRIPLE=x86_64-macos; SYSNAME=Darwin; SYSPROC=x86_64 ;;
mac-arm64) TRIPLE=aarch64-macos; SYSNAME=Darwin; SYSPROC=aarch64 ;;
*) echo "usage: $0 <win|mac-x64|mac-arm64> [dep ...]" >&2; exit 1 ;;
musl) TRIPLE=x86_64-linux-musl; SYSNAME=Linux; SYSPROC=x86_64 ;;
*) echo "usage: $0 <win|mac-x64|mac-arm64|musl> [dep ...]" >&2; exit 1 ;;
esac
shift
@ -203,6 +210,7 @@ build_openssl() {
win) OSSL_TARGET="mingw64" ;;
mac-x64) OSSL_TARGET="darwin64-x86_64-cc" ;;
mac-arm64) OSSL_TARGET="darwin64-arm64-cc" ;;
musl) OSSL_TARGET="linux-x86_64" ;;
*) echo "build_openssl: unknown target $T" >&2; return 1 ;;
esac
@ -450,6 +458,14 @@ build_tcl() {
CFG="$SRC/configure"
LIB="libtcl90.a"
HOSTTRIPLE="x86_64-w64-mingw32"
elif [ "$T" = "musl" ]; then
# musl: the SIMPLEST arm. musl is still Unix, so this is the same unix/configure the native
# Linux build uses (Makefile TCLLIB rule) -- no win/ and no macosx/ sources are involved, so
# the trimmed vendor/tcl tree is sufficient. Only the toolchain and --host differ.
SRC="$R/vendor/tcl/unix"
CFG="$SRC/configure"
LIB="libtcl9.0.a"
HOSTTRIPLE="x86_64-linux-musl"
else
# mac-x64 / mac-arm64: unix/configure, driven out-of-tree from $O/tcl.
# GOTCHA: like win/, the trimmed vendor/tcl also lacks the macosx/ subdir,
@ -488,6 +504,29 @@ build_tcl() {
RANLIB="$RANLIB" \
RC="$WINRC" \
> cfg.log 2>&1 || { echo "build_tcl: configure failed (see $BUILD/cfg.log)" >&2; exit 1; }
elif [ "$T" = "musl" ]; then
# musl: no tcl_cv_sys_version override -- the target IS Linux, so configure's `uname -r`
# probe on this Linux host reports the right system.
# GOTCHA 1: configure sees the HOST's <sys/epoll.h> and selects Tcl's epoll notifier, whose
# tclEpollNotfy.c includes <sys/queue.h> -- a BSD/glibc header musl does not ship, so the
# build dies there. Tell configure the epoll headers are absent and Tcl falls back to its
# portable select() notifier (tclUnixNotfy.c), which musl supports fully.
# GOTCHA 2: zlib. With no zlib visible, configure sets TCL_WITH_INTERNAL_ZLIB but the unix
# Makefile only puts compat/zlib on the include path of the zlib objects themselves, so
# tclEvent.c then fails on a missing <zlib.h>. Point it at the SAME vendored zlib the rest
# of calog cross-links (build_codecs, which runs before tcl in the dep order) and the
# normal system-zlib path is taken instead -- header from vendor/zlib, archive from $O/lib.
"$CFG" \
--host="$HOSTTRIPLE" \
--disable-shared \
ac_cv_header_sys_epoll_h=no \
ac_cv_header_sys_eventfd_h=no \
CPPFLAGS="-I$R/vendor/zlib" \
LDFLAGS="-L$O/lib" \
CC="$CC" \
AR="$AR" \
RANLIB="$RANLIB" \
> cfg.log 2>&1 || { echo "build_tcl: configure failed (see $BUILD/cfg.log)" >&2; exit 1; }
else
# mac: unix/configure via --host with an Apple-Darwin triple.
# tcl_cv_sys_version=Darwin-24.0.0 is REQUIRED: it short-circuits the
@ -554,6 +593,7 @@ build_mruby() {
win) buildname=cross-mingw; hosttriple=x86_64-w64-mingw32 ;;
mac-x64) buildname=cross-mac-x64; hosttriple=x86_64-apple-darwin ;;
mac-arm64) buildname=cross-mac-arm64; hosttriple=aarch64-apple-darwin ;;
musl) buildname=cross-musl; hosttriple=x86_64-linux-musl ;;
esac
# Windows target links executables with .exe; also gates for_windows? source paths in gems.
@ -592,6 +632,12 @@ MRuby::CrossBuild.new('$buildname') do |conf|
conf.cc.command = "$CC"
conf.cc.flags << '-O2'
conf.cc.defines << 'MRB_INT64'
# Per-instruction VM hook (mrb_state.code_fetch_hook), which calog's adapter installs to enforce a
# per-context wall-clock budget. Off by default in mruby, and it CHANGES THE mrb_state LAYOUT, so
# it has to match src/mruby/build_config.rb and the -DMRB_USE_DEBUG_HOOK the adapter compile in
# cross{Win,Mac}Full.sh passes. Without it the adapter does not compile against this library.
# Host/mrbc does not need it: it affects the VM, not the bytecode mrbc emits (unlike MRB_INT64).
conf.cc.defines << 'MRB_USE_DEBUG_HOOK'
conf.linker.command = "$CC"
@ -752,6 +798,15 @@ build_libarchive() {
>"$O/la-cfg.log" 2>&1
cmake --build "$O/libarchive" --target archive_static -j >"$O/la-build.log" 2>&1
echo " [lib] libarchive.a (codecs only; win xar via XmlLite/CNG) ($T)"
elif [ "$T" = musl ]; then
# musl has its own libarchive route -- tools/crossArchive.sh, which handles the iconv gotcha
# for it (libarchive's FIND_PATH(iconv.h) otherwise returns the HOST's glibc /usr/include and
# breaks the musl compile; see PORTING.md). Refuse rather than fall through to the macOS
# branch below, which would build a musl archive with an Apple SDK iconv.h and a Darwin .tbd
# link stub, and reference the mac-only openssl-repack archives. It reported success when it
# did that, which is exactly why this guard is explicit.
echo " libarchive ($T): use tools/crossArchive.sh for musl (see PORTING.md)" >&2
return 1
else
# macOS: libarchive gates libxml2 (hence xar) on a working iconv, but zig's SDK-less libSystem
# stub exports no iconv symbols, so the detection link-test fails and xar silently stubs out.
@ -798,6 +853,16 @@ build_libarchive() {
# Dispatch: build the requested deps (or all, in dependency order).
# ===================================================================================================
ALL="codecs xz openssl libxml2 pcre2 libssh2 mariadb postgres tcl mruby winpthreads libarchive"
# winpthreads is POSIX threads FOR WINDOWS: meaningless on any other target, so it is not in the
# default list for them (asking for it by name still runs, and still fails, which is honest).
if [ "$T" != win ]; then
ALL="codecs xz openssl libxml2 pcre2 libssh2 mariadb postgres tcl mruby libarchive"
fi
# musl: libarchive is tools/crossArchive.sh's job (see build_libarchive), and winpthreads is
# Windows-only. Asking for either by name still runs, and still explains itself.
if [ "$T" = musl ]; then
ALL="codecs xz openssl libxml2 pcre2 libssh2 mariadb postgres tcl mruby"
fi
DEPS=${*:-$ALL}
# Tolerant dispatch: build every requested dep, collect failures, and report at the end (so one
# broken dep -- e.g. tcl when vendor/tcl lacks win//macosx/ -- does not abort the rest). The `if`

View file

@ -155,7 +155,9 @@ build_arch(){
cc berryAdapter src/berry/berryAdapter.c -Ivendor/berry/src -Ivendor/berry
cc s7Adapter src/s7/s7Adapter.c -Ivendor/s7
cc wrenAdapter src/wren/wrenAdapter.c -Ivendor/wren
cc mrubyAdapter src/mruby/mrubyAdapter.c -Ivendor/mruby/include -Ivendor/mruby/build/cross-$A/include
# -DMRB_USE_DEBUG_HOOK must match the cross libmruby.a (tools/crossDeps.sh sets the same define):
# it adds mrb_state.code_fetch_hook, so the adapter and the library have to agree on the layout.
cc mrubyAdapter src/mruby/mrubyAdapter.c -DMRB_USE_DEBUG_HOOK -Ivendor/mruby/include -Ivendor/mruby/build/cross-$A/include
cc tclAdapter src/tcl/tclAdapter.c -Ivendor/tcl/generic -DSTATIC_BUILD
cc janetAdapter src/janet/janetAdapter.c -Ivendor/janet
cc calogMain src/calogMain.c $ENGDEF

View file

@ -76,7 +76,9 @@ cc mybasicAdapter src/mybasic/mybasicAdapter.c $BASE -Ivendor/ourbasic -DMB_DOUB
cc berryAdapter src/berry/berryAdapter.c $BASE -Ivendor/berry/src -Ivendor/berry
cc s7Adapter src/s7/s7Adapter.c $BASE -Ivendor/s7
cc wrenAdapter src/wren/wrenAdapter.c $BASE -Ivendor/wren
cc mrubyAdapter src/mruby/mrubyAdapter.c $BASE -Ivendor/mruby/include -Ivendor/mruby/build/cross-mingw/include
# -DMRB_USE_DEBUG_HOOK must match the cross libmruby.a (tools/crossDeps.sh sets the same define):
# it adds mrb_state.code_fetch_hook, so the adapter and the library have to agree on the layout.
cc mrubyAdapter src/mruby/mrubyAdapter.c $BASE -DMRB_USE_DEBUG_HOOK -Ivendor/mruby/include -Ivendor/mruby/build/cross-mingw/include
cc tclAdapter src/tcl/tclAdapter.c $BASE -Ivendor/tcl/generic -DSTATIC_BUILD
cc janetAdapter src/janet/janetAdapter.c $BASE -Ivendor/janet
cc calogMain src/calogMain.c $BASE $ENGDEF

View file

@ -51,3 +51,21 @@ Verified: full calog `make test` (28 binaries, 0 failed) plus the my-basic engin
suite (testMyBasic / testEngineMyBasic / testPolyglot / testLoad / testTask),
ASan/UBSan-clean, with def and lambda callbacks firing via timer/pubsub/export and
cross-engine (a Lua task calling a my-basic def).
Portability -- number classification does not trust endptr alone
- `_get_symbol_type` (and VAL / INPUT) decided "this symbol is a number" from
strtoll/strtod alone reaching the string terminator. That test is not portable:
musl's strtoll ADVANCES endptr past leading whitespace and a sign even when no
conversion happens, where glibc leaves endptr at the start. On musl this made
"+" and "-" -- the operators -- and "\n" -- the statement separator -- each
classify as the integer 0, so every expression containing an operator failed to
parse and every numeric assignment failed to run, both with "Operator expected".
A new `_conv_matched_nothing(start, end)` rejects a consumed span that is nothing
but whitespace and sign; a span that consumed anything else (digits, "inf",
"nan") classifies exactly as before, on every libc, and an empty span is left
alone so VAL("")/INPUT with an empty line keep their existing behavior.
Reproduced with a pure my-basic program (no calog code): `x = 1` returned
MB_FUNC_ERR on musl and MB_FUNC_OK on glibc.
Verified: testEngineMyBasic 20/20 on a fully static musl build via zig (was 13 of 20
failing), unchanged 20/20 natively, and calog `make test` 943 checks / 0 failed.

View file

@ -1541,6 +1541,7 @@ static int _cut_symbol(mb_interpreter_t* s, int pos, unsigned short row, unsigne
static int _append_symbol(mb_interpreter_t* s, char* sym, bool_t* delsym, int pos, unsigned short row, unsigned short col);
static int _create_symbol(mb_interpreter_t* s, _ls_node_t* l, char* sym, _object_t** obj, _ls_node_t*** asgn, bool_t* delsym);
static _data_e _get_symbol_type(mb_interpreter_t* s, char* sym, _raw_t* value);
static bool_t _conv_matched_nothing(const char* start, const char* end);
static int _parse_char(mb_interpreter_t* s, const char* str, int n, int pos, unsigned short row, unsigned short col);
static void _set_error_pos(mb_interpreter_t* s, int pos, unsigned short row, unsigned short col);
static char* _prev_import(mb_interpreter_t* s, char* lf, int* pos, unsigned short* row, unsigned short* col);
@ -3629,6 +3630,14 @@ static char _get_priority(mb_func_t op1, mb_func_t op2) {
idx1 = _get_priority_index(op1);
idx2 = _get_priority_index(op2);
/* [calog fork] _get_priority_index returns -1 for an operator that is not in its table, and the
* assert guarding this lookup only checks the UPPER bound -- so _PRECEDE_TABLE[-1][...] is an
* out-of-bounds read. It is masked today because mb_assert is a live assert() here, but an
* embedder building with NDEBUG compiles that away and reads out of bounds silently. Report the
* table's own "cannot operate" marker instead; the caller already turns ' ' into a clean
* SE_RN_FAILED_TO_OPERATE script error, which is the right answer for an unknown operator. */
if(idx1 < 0 || idx2 < 0)
return ' ';
mb_assert(idx1 < countof(_PRECEDE_TABLE) && idx2 < countof(_PRECEDE_TABLE[0]));
result = _PRECEDE_TABLE[idx1][idx2];
@ -5549,6 +5558,28 @@ _exit:
}
/* Get the type of a syntax symbol */
/* [calog fork] Companion to the "endptr reached the terminator" test that classifies a symbol as a
* number. That test alone is not portable: musl's strtoll ADVANCES endptr past leading whitespace and
* a sign even when no conversion happens, while glibc leaves it at the start. So on musl "+" and "-"
* -- the operators -- and "\n" -- the statement separator -- were every one of them reported as the
* integer 0: every expression containing an operator failed to parse, and every numeric assignment
* failed to run, both with "Operator expected". A span of nothing but whitespace and sign is never a
* number, so that is what to reject. Anything that consumed something else (digits, "inf", "nan") is
* classified exactly as before, on every libc, and an empty span is left alone -- that is the classic
* "no conversion" the callers already handle as they always have. */
static bool_t _conv_matched_nothing(const char* start, const char* end) {
const char* p = 0;
if(end <= start)
return false;
for(p = start; p < end; p++) {
if(!(*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == '\f' || *p == '\v' || *p == '+' || *p == '-'))
return false;
}
return true;
}
static _data_e _get_symbol_type(mb_interpreter_t* s, char* sym, _raw_t* value) {
_data_e result = _DT_NIL;
union { real_t float_point; int_t integer; _object_t* obj; _raw_t any; } tmp;
@ -5570,7 +5601,7 @@ static _data_e _get_symbol_type(mb_interpreter_t* s, char* sym, _raw_t* value) {
/* int_t */
tmp.integer = (int_t)mb_strtol(sym, &conv_suc, 0);
if(*conv_suc == _ZERO_CHAR) {
if(*conv_suc == _ZERO_CHAR && !_conv_matched_nothing(sym, conv_suc)) {
memcpy(*value, tmp.any, sizeof(_raw_t));
result = _DT_INT;
@ -5579,7 +5610,7 @@ static _data_e _get_symbol_type(mb_interpreter_t* s, char* sym, _raw_t* value) {
}
/* real_t */
tmp.float_point = (real_t)mb_strtod(sym, &conv_suc);
if(*conv_suc == _ZERO_CHAR) {
if(*conv_suc == _ZERO_CHAR && !_conv_matched_nothing(sym, conv_suc)) {
memcpy(*value, tmp.any, sizeof(_raw_t));
result = _DT_REAL;
@ -18143,14 +18174,14 @@ static int _std_val(mb_interpreter_t* s, void** l) {
switch(arg.type) {
case MB_DT_STRING:
ret.value.integer = (int_t)mb_strtol(arg.value.string, &conv_suc, 0);
if(*conv_suc == _ZERO_CHAR) {
if(*conv_suc == _ZERO_CHAR && !_conv_matched_nothing(arg.value.string, conv_suc)) {
ret.type = MB_DT_INT;
mb_check(mb_push_value(s, l, ret));
goto _exit;
}
ret.value.float_point = (real_t)mb_strtod(arg.value.string, &conv_suc);
if(*conv_suc == _ZERO_CHAR) {
if(*conv_suc == _ZERO_CHAR && !_conv_matched_nothing(arg.value.string, conv_suc)) {
ret.type = MB_DT_REAL;
mb_check(mb_push_value(s, l, ret));
@ -18699,14 +18730,14 @@ static int _std_input(mb_interpreter_t* s, void** l) {
_get_inputer(s)(s, pmt, line, sizeof(line));
obj->data.variable->data->type = _DT_INT;
obj->data.variable->data->data.integer = (int_t)mb_strtol(line, &conv_suc, 0);
if(*conv_suc == _ZERO_CHAR) {
if(*conv_suc == _ZERO_CHAR && !_conv_matched_nothing(line, conv_suc)) {
#if MB_PRINT_INPUT_CONTENT
_get_printer(s)(s, MB_INT_FMT "\n", obj->data.variable->data->data.integer);
#endif /* MB_PRINT_INPUT_CONTENT */
} else {
obj->data.variable->data->type = _DT_REAL;
obj->data.variable->data->data.float_point = (real_t)mb_strtod(line, &conv_suc);
if(*conv_suc == _ZERO_CHAR) {
if(*conv_suc == _ZERO_CHAR && !_conv_matched_nothing(line, conv_suc)) {
#if MB_PRINT_INPUT_CONTENT
_get_printer(s)(s, MB_REAL_FMT "\n", obj->data.variable->data->data.float_point);
#endif /* MB_PRINT_INPUT_CONTENT */