Minor API change dealing with script exiting.

This commit is contained in:
Scott Duensing 2026-07-26 20:37:13 -05:00
parent fe0fe73884
commit 86e3a1d71a
9 changed files with 54 additions and 91 deletions

3
API.md
View file

@ -18,8 +18,6 @@ etc. -- see the README and `src/calog.h`.)
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.
@ -113,7 +111,6 @@ 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. |
| `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

@ -236,7 +236,7 @@ weeks. `make cross` runs the real thing; `make cross-smoke`, `cross-win`, `cross
### 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 --
The cross-builds were re-run after the teardown 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:
@ -285,7 +285,7 @@ run exposed three problems that all predate that work. All three are now fixed:
| `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
(the abort message is 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

View file

@ -150,10 +150,10 @@ bin/calog producer.js consumer.lua # several files share one runtime (kv, pubsu
```
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
everything down (calog is event-driven, so a script asks to stop; `Ctrl-C` also works). It does
not return -- the statement after it never runs, so a build script's `calogExit(1)` is final. A
script can also end just itself with `taskExit()`, leaving its siblings running; 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.
@ -414,7 +414,7 @@ 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)
// stopping scripts (what bin/calog's calogExit is 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

View file

@ -1510,7 +1510,7 @@ handler, that the runtime stays latched, and that a later `calogCall` is refused
---
## 26. Reclaiming a context's callables -- and `calogEnd`
## 26. Reclaiming a context's callables -- and ending one script
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
@ -1532,7 +1532,7 @@ 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` |
| a library registry | `psSubscribe('t', function(){});` then exit, error, or `taskExit` |
| 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 |
@ -1540,7 +1540,7 @@ The first instinct -- move the pubsub and export destroy hooks from `calogDestro
`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.
or `taskExit`. It also cannot reach a value another VM is holding, where no registry is involved.
### The fix: the owner reclaims, on its own thread
@ -1633,37 +1633,48 @@ and freed moments later by the teardown that already owns it.
runtime -- clean under ASan across repeated runs, and under ThreadSanitizer with the Lua and
JavaScript engines linked.
### `calogEnd([code])` -- ending one script
### Ending one script -- `calogAbortCurrent`, and the runner native that was not needed
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.
With reclamation in place a context can safely end itself, so the core gained `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.
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:
The runner briefly exposed this as a script native, `calogEnd([code])`, and it was **removed again**
after measuring what it actually added. Two things had been conflated:
- **Reaping a self-ended script** -- the thing that was actually missing -- is a RUNNER fix, not a
new native. The pump loop only closed contexts flagged `failed`, so a context that retired itself
sat in `gLaunched` with `liveCount` never dropping and the runner never exiting. Closing any
context whose thread has finished is what fixed that, and `taskExit` (which has always retired the
calling context) inherits it for free: a lone script ending with `taskExit()` now exits the run.
- **What was left** once reaping worked was immediacy plus an optional exit code. `error(...)`
already ends one script with a failing status while its siblings continue, and `calogExit(code)`
names the status when you want everything to stop -- leaving `calogEnd` a third verb for the
narrow slice of "a specific non-zero status from one script while the others carry on".
So the runner keeps two verbs and the library keeps its own:
| | ends | process status |
|---|---|---|
| `calogEnd([code])` | this script | `code` if given, else unclaimed |
| `taskExit()` | this script (deferred -- the current chunk finishes) | unclaimed |
| `error(...)` | this script | 1 (the run did not fully succeed) |
| `calogExit([code])` | everything | `code` (default 0) |
| `calogExit([code])` | everything, immediately | `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.
`calogAbortCurrent` stays in the public API: it is the counterpart to `calogAbortAll`, it is what an
embedder registers their own per-script "stop" over (the runner is only one consumer of this
library), and `tests/testTeardown.c` drives the early-context-death reclaim path through it.
### 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
first; a script that ends itself with `calogAbortCurrent`; 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.

View file

@ -24,8 +24,6 @@ Conventions every example follows:
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

View file

@ -271,8 +271,8 @@ bool calogCurrentShuttingDown(void); // true once the calling context
// 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
// Stop only the CALLING script, leaving the runtime and every other script running. 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);

View file

@ -82,10 +82,8 @@ 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);
@ -211,27 +209,6 @@ static const char *extensionOf(const char *arg) {
}
// 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 (!readExitCode(args, argCount, result, &code, &given, "calogEnd")) {
return calogErrArgE;
}
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.
@ -239,12 +216,18 @@ static int32_t nativeCalogEnd(CalogValueT *args, int32_t argCount, CalogValueT *
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;
code = 0;
if (argCount >= 1) {
if (args[0].type == calogIntE) {
code = (int32_t)args[0].as.i;
} else if (args[0].type == calogRealE) {
code = (int32_t)args[0].as.r;
} else {
return calogFail(result, calogErrArgE, "calogExit expects an optional integer exit code");
}
}
requestShutdown(code);
calog = calogCurrent();
@ -417,31 +400,6 @@ 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);
@ -572,7 +530,6 @@ 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;
@ -639,7 +596,7 @@ int main(int argc, char **argv) {
}
// Service script->native calls until a script calls calogExit, we are signalled, or every
// launched context is gone -- each having either ended itself with calogEnd or errored out.
// launched context is gone -- each having either retired itself (taskExit) or errored out.
// Both are reaped here; when the last live context is gone, the runner exits.
while (!atomic_load(&gShutdown)) {
calogPump(calog);
@ -654,9 +611,9 @@ int main(int argc, char **argv) {
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.
// The script retired itself (taskExit) and its thread has exited. Close joins it
// and frees the context -- the reaping that lets a self-ended script end the run when
// it was the last one. Not a failure, so the exit code is left to whatever names one.
calogContextClose(gLaunched[index].context);
gLaunched[index].context = NULL;
liveCount--;

View file

@ -106,7 +106,7 @@ 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
_Atomic bool aborting; // calogAbortCurrent: 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

View file

@ -63,8 +63,8 @@ static void checkImpl(bool condition, const char *message, int32_t line) {
}
// 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.
// calogAbortCurrent: 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;
@ -212,7 +212,7 @@ static void testHeldCallableSurvivesTeardown(const char *what, const char *sourc
// 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
// the RUNTIME lives on -- a script that errors out, or one that ends itself -- 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) {
@ -336,7 +336,7 @@ int main(void) {
"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)",
testOwnerDiesBeforeTheRuntime("a JS subscriber whose script ends itself",
"psSubscribe('t', function () {}); ready(); endSelf();", false);
testCrossEngineValueOutlivesOwner();
testGuardsAfterShutdown();