diff --git a/API.md b/API.md index f77c779e..3c3735a7 100644 --- a/API.md +++ b/API.md @@ -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 diff --git a/PORTING.md b/PORTING.md index 23408095..1636bea7 100644 --- a/PORTING.md +++ b/PORTING.md @@ -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 diff --git a/README.md b/README.md index 3713036d..fde880a6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/design.md b/design.md index d4a354fb..51714859 100644 --- a/design.md +++ b/design.md @@ -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. diff --git a/examples/scripts/README.md b/examples/scripts/README.md index bee0d62d..13327b14 100644 --- a/examples/scripts/README.md +++ b/examples/scripts/README.md @@ -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 diff --git a/src/calog.h b/src/calog.h index e2366433..2b708733 100644 --- a/src/calog.h +++ b/src/calog.h @@ -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); diff --git a/src/calogMain.c b/src/calogMain.c index ab3a6c5c..0d15d008 100644 --- a/src/calogMain.c +++ b/src/calogMain.c @@ -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--; diff --git a/src/context.c b/src/context.c index 2528eaa0..30ec5e4f 100644 --- a/src/context.c +++ b/src/context.c @@ -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 diff --git a/tests/testTeardown.c b/tests/testTeardown.c index 36e4f758..02ddaeed 100644 --- a/tests/testTeardown.c +++ b/tests/testTeardown.c @@ -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();