Some more changs to tighten up security a bit.
This commit is contained in:
parent
86e3a1d71a
commit
f02f62a61d
34 changed files with 2095 additions and 332 deletions
42
API.md
42
API.md
|
|
@ -18,6 +18,10 @@ 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)`.
|
||||
`Ctrl-C` (or `SIGTERM`) ends a run the same way, reporting `130`/`143`: it aborts the running
|
||||
scripts instead of waiting for them, so a script busy in a loop stops promptly. A script that
|
||||
calls no natives at all is stopped by its interpreter's own hook on every engine except **s7**,
|
||||
which exposes no usable hook -- an s7 script spinning without calling anything can only be killed.
|
||||
- **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.
|
||||
|
|
@ -106,12 +110,41 @@ egress (JavaScript, Berry, s7, mruby); Janet loses `symbol`/`keyword` subtype (i
|
|||
|
||||
---
|
||||
|
||||
## What the engines' own standard libraries do NOT give you
|
||||
|
||||
calog's contract is that a script reaches the host **only** through the natives documented below.
|
||||
That is the one place a policy can see it: the per-context allow-list, the memory cap and the
|
||||
wall-clock budget all live at the native-dispatch choke point. An engine's own filesystem, process,
|
||||
socket, environment or dynamic-loading bindings walk straight around all three, so calog removes them
|
||||
at interpreter creation. This is not limited to sandboxed contexts -- an engine built-in that ends
|
||||
the host process or dlopens a library is wrong in an ordinary run too.
|
||||
|
||||
| engine | removed | use instead |
|
||||
|---|---|---|
|
||||
| Lua | `io`, `os`, `package`/`require`, `debug`, `dofile`, `loadfile`, `string.dump`; `load` is text-only | `fs*`, `procRun`, `calogExit`, `time*` |
|
||||
| Tcl | `exec`, `open`, `socket`, `load`, `exit`, `source`, `glob`, `file` (and `::tcl::file::*`), `cd`, `pwd`, `zipfs`, `after`, `vwait`, the channel commands, `::env` | `fs*`, `procRun`, `net*`, `calogExit`, `timer*` |
|
||||
| s7 | `system`, `exit`, `emergency-exit`, `abort`, `load`, the file ports, `getenv`, `file-exists?`, `directory->list` | `fs*`, `procRun`, `calogExit` |
|
||||
| Berry | `os` module (`system`, `exit`, `chdir`, ...), `open`, `compile(..., "file")`, `import` of a `.so`/`.bec`, `introspect` | `fs*`, `procRun`, `calogExit` |
|
||||
| Janet | `file/*`, `os/execute`, `os/exit`, `os/cwd`, `os/getenv`, `net/*`, `ffi/*`, dynamic modules | `fs*`, `procRun`, `net*`, `calogExit` |
|
||||
| my-basic | `IMPORT "path"` (module imports `IMPORT "@name"` still work) | `taskLoad`, `calogExport` |
|
||||
| JavaScript, Squirrel, mruby, Wren | nothing to remove -- these ship no host bindings | -- |
|
||||
|
||||
Pure computation is untouched everywhere: strings, collections, math, regex, closures, coroutines,
|
||||
classes and each language's own control flow all work normally. What is gone is the ability to touch
|
||||
the machine without going through a native the host registered and can revoke.
|
||||
|
||||
Three capabilities have no native equivalent today and are simply unavailable: reading environment
|
||||
variables, renaming a file, and creating a temp file by name. A host that wants a script to have any
|
||||
of them registers a native for it -- which is the point, because then it is policy-controlled.
|
||||
|
||||
---
|
||||
|
||||
## Runner (`calog` binary)
|
||||
|
||||
| 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** -- 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. |
|
||||
| `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, or at its interpreter's next hook if it calls none. The first caller's `code` is the one reported; a script that catches the unwind still cannot call another native. |
|
||||
|
||||
## archive
|
||||
|
||||
|
|
@ -378,6 +411,13 @@ task is owned by the context that spawned it, and only that owner may `taskEval`
|
|||
`taskClose` is cooperative -- a task busy in a pure loop must poll `taskActive()` and break
|
||||
out, or the close blocks until the task next returns to the runtime.
|
||||
|
||||
**A task cannot be used to escape a sandbox.** When the spawning script runs under limits, the child
|
||||
does not get a fresh budget -- it SHARES the parent's: one memory pool for the whole tree, the same
|
||||
absolute deadline (not a new one per child), and the same allow-list. A host that also sets
|
||||
`maxContexts` bounds how many contexts the tree may hold at once, and a `taskSpawn`/`taskLoad` past
|
||||
that bound fails instead of being granted. An unsandboxed script spawns unrestricted children exactly
|
||||
as before.
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `taskSpawn(engine: string, code: string) -> handle` | Run a code string on a named engine (`"lua"`, `"js"`, `"squirrel"`, `"mybasic"`, `"berry"`, `"s7"`, `"wren"`, `"mruby"`, `"tcl"`, `"janet"`). |
|
||||
|
|
|
|||
621
AUDIT.md
621
AUDIT.md
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
Original audit: 15 scoped reviewers + 1 adversarial verifier per finding (118 raw, 4 refuted, 114 confirmed).
|
||||
|
||||
A **follow-on audit** is appended at the end of this file: 23 findings, IDs prefixed `S`, from the
|
||||
2026-07-24/27 session. Different provenance (one reported defect and what pulling on it uncovered),
|
||||
same format. It is a separate body of work -- the counts in this section cover the original 114 only.
|
||||
|
||||
## Remediation status
|
||||
|
||||
**110 FIXED, 2 PARTIAL, 1 DECLINED, 1 KEPT.** Verified: `make all` clean (-Werror -Wconversion, ASan/UBSan), `make test` 28/28 binaries 0 failed, TSan (actor + mybasic) clean, 31 example scripts + multi-file pass.
|
||||
|
|
@ -1391,3 +1395,620 @@ s->ast is an _ls_create() sentinel list whose `prev` always points at the tail:
|
|||
*Verifier:* Verified in vendor/ourbasic/ourBasic.c: mb_eval_routine_cold (19531-19533) walks the whole AST to find the tail, yet the list's tail pointer invariant is airtight -- s->ast is _ls_create()'d (12308), every append goes through _ls_pushback(s->ast,...) (5623, 6100, 6108, 6148, 6153) which always sets list->prev to the new tail (2471), and the only removals are wholesale _ls_clear/_ls_destroy (12440, 12346) where _ls_clear resets prev=0 (2752). Upstream code already uses the proposed idiom `_ls_back(s->ast)` for the same purpose at line 11289, proving the invariant is relied upon. The sole caller is the callback dispatch in src/mybasic/mybasicAdapter.c:453 (timer/pubsub/export), so the O(n) walk runs on every callback fire. The empty-AST bonus point is also accurate (sentinel's data is the count per line 2472, not an _object_t). Concrete one-line equivalent fix; no behavior change in reachable states.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# Follow-on audit -- 2026-07-24/27 session (23 findings)
|
||||
|
||||
Provenance differs from the audit above, and it matters when reading these. They were not produced by
|
||||
a scoped reviewer sweep. They came out of **one reported defect** -- `calogExit` did not behave as
|
||||
`API.md` documented -- and everything that pulling on it uncovered: adversarial review agents run
|
||||
against each proposed core change before it was written, the first end-to-end run of the cross-builds
|
||||
in weeks, and a churn test written for one race that immediately found two more. Several were found
|
||||
by *reproducing* rather than by reading, which is noted per finding.
|
||||
|
||||
Two entries are self-inflicted and say so (S8, and half of S6): they were introduced by the fix for
|
||||
an earlier finding in this same session. An audit that hides that is worth less than one that does
|
||||
not.
|
||||
|
||||
One design change is recorded here but is not a finding: a runner native `calogEnd([code])` was added
|
||||
(2026-07-24) to end a single script, then **removed** (2026-07-26) once measurement showed the
|
||||
capability actually wanted -- reaping a self-ended script -- was a runner fix that `taskExit` already
|
||||
inherits, leaving `calogEnd` a third verb for a narrow slice. See design.md sec 26.
|
||||
|
||||
## Remediation status
|
||||
|
||||
**22 FIXED, 1 KEPT.** Verified: `make test` **959 checks / 0 failed** (with the new zig-free
|
||||
`cross-lint` running inside it); ThreadSanitizer clean on the actor core, the libraries, the JS path
|
||||
and my-basic; `tools/crossBuild.sh` **39 ok / 0 failed**, all ten engines built for four targets and
|
||||
**run** statically on musl (151 checks across 12 binaries); Windows PE and macOS x86_64/arm64 full
|
||||
CLIs build; every shipped example runs.
|
||||
|
||||
New regression tests: `tests/testExit.c` (66 checks, all ten engines), `tests/testTeardown.c` (29
|
||||
checks, JavaScript on purpose -- QuickJS is the only VM that reports a stranded handle), plus cases
|
||||
added to `tests/testHooks.c` and `tests/testTask.c`.
|
||||
|
||||
---
|
||||
|
||||
## HIGH (8)
|
||||
|
||||
### S1. [bug] src/calogMain.c:85 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**`calogExit` returned, contradicting `API.md`'s "Does not return": work after the exit still ran, and a later `calogExit` silently overwrote an earlier one's code.**
|
||||
|
||||
The native set two atomics and handed control back to the script, which ran on to the end of its
|
||||
chunk while the host pump was still a tick (0.5 ms) from noticing. Two silent consequences:
|
||||
`if (failed) { calogExit(1) } ... deploy()` deployed, and a script ending in `calogExit(0)` reported
|
||||
success no matter which failure had asked for `1` first. This is the reported defect that started the
|
||||
session; it is a footgun for exactly the use calog is good at -- a build/CI script in whichever
|
||||
language suits the job. Fixed by making the native latch the runtime (`calogAbortAll`) and return an
|
||||
error the engine raises, so the script unwinds at the call site; exit-code precedence became
|
||||
first-writer-wins so a failure cannot be masked.
|
||||
|
||||
*Verifier:* Reproduced and re-verified end to end on **all ten engines** through `bin/calog`: before,
|
||||
`calogPrint("before"); calogExit(3); calogPrint("AFTER"); calogExit(0)` printed `AFTER` and exited 0;
|
||||
after, it prints only `before` and exits **3**, with empty stderr on every engine. Also verified from
|
||||
a timer callback (rc 7), from a spawned task (rc 6), and that a script catching the unwind with
|
||||
`pcall` cannot call another native afterwards.
|
||||
|
||||
---
|
||||
|
||||
### S2. [bug] src/context.c:1301, src/value.c:269 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**A script function outliving its owning interpreter aborted the process; three unrelated holders reproduce it, and the obvious destroy-phase fix does not close any of them.**
|
||||
|
||||
A `CalogFnT` is a handle into one VM. If anything still holds one when that context's interpreter is
|
||||
destroyed, the engine-side release never runs -- `calogFnFinalize` sees the owner gone and frees only
|
||||
the shell. Most VMs hide it; QuickJS asserts it owns no live objects and aborts:
|
||||
`quickjs.c:2682: JS_FreeRuntime: Assertion 'list_empty(&rt->gc_obj_list)' failed`. Three holders
|
||||
reproduce, all deterministically: a library registry (`psSubscribe`/`calogExport`/`timerEvery` with a
|
||||
JS callback), **another engine** (a JS closure handed to a Lua script via `calogCall`, no registry
|
||||
involved at all), and an in-flight invoke. The first instinct -- move the pubsub/export destroy hooks
|
||||
to `calogDestroyBeforeContextsE` as `calogTimer` already did -- fixes only whole-runtime teardown, and
|
||||
only for the last runtime in the process; it does nothing for a context that dies while the runtime
|
||||
lives on (a script erroring after subscribing, `taskExit`), which is the common case. Fixed at the
|
||||
level that holds: each context lists the callables it creates, and `threadMain` reclaims them on its
|
||||
own thread before `destroyInterpreter` -- mark dead, run the engine release, so every later holder
|
||||
finalizes an empty shell. **No destroy phase changed.**
|
||||
|
||||
*Verifier:* All six repro shapes re-run against the fixed binary: 0 assertions, correct exit codes.
|
||||
The cross-VM case was isolated specifically to prove no registry was involved. An adversarial review
|
||||
of the *proposed* phase-change fix caught that it was insufficient **before it shipped**, and the
|
||||
insufficiency was then confirmed by reproducing the early-death case directly.
|
||||
|
||||
---
|
||||
|
||||
### S3. [bug] src/context.c:375 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**`calogActorShutdown` walked the context registry without holding `ctxMutex`, while scripts were still free to create contexts -- so a context could be freed with its thread running.**
|
||||
|
||||
The walk read `calog->ctxSlots` and `calog->ctxCount` unlocked, and that walk is exactly when scripts
|
||||
are still executing and may call `taskSpawn`/`taskLoad`, each of which can `realloc` the slot array
|
||||
underneath it. A context registered after the walk passed its slot was then freed by the third loop
|
||||
with its thread still live, and that thread outlived the array it was registered in. Compounding it,
|
||||
`calogContextOpen` 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 flag said otherwise --
|
||||
skipped by both the shutdown-request and join loops. Fixed by latching the registry closed
|
||||
(`tearingDown`, src/context.c:387) under `ctxMutex` as the teardown's first act, reading every slot
|
||||
under that lock, and moving `pthread_create` + `started = true` inside the registration critical
|
||||
section.
|
||||
|
||||
*Verifier:* `tests/testHooks.c` pins it deterministically: a per-context shutdown hook runs on the
|
||||
context's own thread *while* `calogDestroy` is walking the registry -- precisely the window -- and
|
||||
opening a context from inside it must now be refused. `tests/testTask.c` adds a `taskSpawn` loop
|
||||
racing `calogDestroy` on its own runtime, clean under ASan across repeated runs.
|
||||
|
||||
---
|
||||
|
||||
### S4. [bug] src/context.c:902 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**Two threads could stop, join and free the same context: `taskClose` is an inline native, so a script thread races the runtime teardown into `pthread_join` on one thread.**
|
||||
|
||||
`taskClose` runs on the calling script's thread and reaches `calogContextClose`, which requests
|
||||
shutdown, joins, unlinks and frees. Nothing stopped that from happening while `calogActorShutdown`
|
||||
was doing the same to the same context. `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 this:
|
||||
the join must not be held under a lock the joined thread may itself need. Fixed with a claim --
|
||||
`closing` per context under `ctxMutex` (src/context.c:920); `tearingDown` is the teardown's claim on
|
||||
every context at once, and `calogActorShutdown` waits out any close already in flight, which is
|
||||
bounded because the latch means the set only shrinks. A `taskClose` during teardown is now a no-op:
|
||||
the context it named is stopped and freed moments later by the teardown that owns it.
|
||||
|
||||
*Verifier:* `tests/testTask.c` races a loop of `taskSpawn` **and** `taskClose` against `calogDestroy`
|
||||
on its own runtime: clean across 25 consecutive ASan runs, and clean under a purpose-built
|
||||
ThreadSanitizer binary with the Lua and JavaScript engines linked.
|
||||
|
||||
---
|
||||
|
||||
### S5. [bug] src/context.c:1529 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**A context's queue accepted work after `serveLoop` stopped serving it: fire-and-forget evals were silently dropped, and a blocking sender could wait on a reply that could never come.**
|
||||
|
||||
`serveLoop` returns when `messageDequeue` finds the queue empty and `shuttingDown` set, but
|
||||
`interpDead` -- what stops `registryResolveLocked` handing the context out -- is set later, after the
|
||||
per-context hooks and the callable reclaim. In that window the context still resolved, so a message
|
||||
enqueued there was accepted and never served. A `messageReleaseE` is drained later by
|
||||
`contextDrainQueue`, but a blocking call leaves its sender waiting on a condvar with no timeout, so
|
||||
`calogDestroy`'s join on that sender never returns. Moving `interpDead` earlier was the wrong fix --
|
||||
the callable reclaim depends on its ordering -- so *closing the queue* was made atomic with the
|
||||
decision to stop serving: `queueClosed` (src/context.c:111) is set inside the same critical section
|
||||
where `messageDequeue` decides to return NULL (:1542), `enqueueRaw` refuses while holding that mutex
|
||||
(:1480), and `contextEnqueue` reports `calogErrDeadE`, which every caller already handled.
|
||||
|
||||
*Verifier:* `tests/testHooks.c` drives the exact window -- a per-context shutdown hook runs after
|
||||
`serveLoop` and before `interpDead` -- and asserts that queueing work from there is refused. The check
|
||||
was confirmed to **fail against the pre-fix code** (built from a copy with the refusal removed) and
|
||||
pass after, which for a window this narrow is the only evidence worth having.
|
||||
|
||||
---
|
||||
|
||||
### S6. [bug] src/context.c:566 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**`ctxMutex` was destroyed while code that locks it was still to run: every after-context destroy hook that releases a callable locked a destroyed mutex.**
|
||||
|
||||
`calogActorShutdown` destroyed `ctxMutex` at its end, but `calogDestroy` runs the
|
||||
`calogDestroyAfterContextsE` hooks *after* it, and those hooks (pubsub, export, kv, task) release
|
||||
what their registries hold. Finalizing a callable locks `ctxMutex` twice over -- to untrack it and,
|
||||
via `calogContextRegistered`, to decide whether the engine release may run. So `calogPubsubShutdown`
|
||||
locked a destroyed mutex on **every** teardown with a subscriber outstanding. Undefined behaviour,
|
||||
deterministic, and invisible to AddressSanitizer. Half of it predates this session: the
|
||||
`calogContextRegistered` lock on that path has always been there; the callable tracking added a
|
||||
second. Fixed by destroying `ctxMutex` at the end of `calogDestroy`, after those hooks -- by then
|
||||
every slot is gone, so the late locks find nothing, which is the answer they want.
|
||||
|
||||
*Verifier:* Found by running the new `tests/testTeardown.c` churn case under ThreadSanitizer, which
|
||||
reported `use of an invalid mutex (e.g. uninitialized or destroyed)` with the full stack
|
||||
`calogPubsubShutdown -> pubsubFreeAll -> calogFnRelease -> calogFnFinalize -> calogContextUntrackFn ->
|
||||
pthread_mutex_lock`. Zero warnings after the fix, across 8 runs.
|
||||
|
||||
---
|
||||
|
||||
### S7. [bug] src/value.c:269 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**Data race on a callable's own fields: the owner's reclaim nulled `release`/`userData` while another thread was reading them to marshal a call.**
|
||||
|
||||
`calogFnReclaim` used to null `release` and `userData` after running the engine release, so a later
|
||||
finalize would not repeat it. But those fields are read on *other* threads by `actorInvokeCallable`
|
||||
(src/context.c:318) -- ThreadSanitizer caught the **timer thread** reading `userData` to marshal a
|
||||
callback at the instant the owner's sweep nulled it. The `alive` flag does not close this: the invoke
|
||||
checks it *before* reading the fields, a plain check-then-use. Fixed by making reclaim change nothing
|
||||
but one atomic: a `reclaimed` flag claimed with `atomic_exchange`, so the engine release runs exactly
|
||||
once whether reclaim or a finalize reaches it first, while `fn`/`userData`/`release` stay
|
||||
write-once-at-create and are safe to read from any thread. An in-flight marshal can still carry a
|
||||
dead callable's fields to the dispatch layer, where it is refused -- the queue was closed before the
|
||||
sweep ran (S5), which is what makes that safe.
|
||||
|
||||
*Verifier:* ThreadSanitizer data-race report with both stacks (write in `calogFnReclaim` from the
|
||||
dying context's thread, previous read in `calogFnUserData` from `timerThreadMain`). Zero warnings
|
||||
across 8 runs after the fix; `tsan`, `tsanlibs`, `tsanjs` and `tsanmb` all clean.
|
||||
|
||||
---
|
||||
|
||||
### S8. [bug] src/context.c:1301 --- FIXED
|
||||
|
||||
**Status: FIXED (self-inflicted -- introduced by the fix for S2)**
|
||||
|
||||
**The reclaim sweep took the callable list under the lock but retained its entries after unlocking, so a foreign last-drop in that gap had it adopt a callable whose finalize was already committed.**
|
||||
|
||||
`contextReclaimCallables` stole the owned-callable list under `queueMutex`, unlocked, and only then
|
||||
retained each entry. A foreign thread dropping the last reference in that window leaves the sweep
|
||||
retaining a callable whose `calogFnRelease` has already committed to finalizing it -- reviving a
|
||||
corpse, and freeing it twice when the sweep drops its own reference. Fixed by making taking the list
|
||||
and taking the references **one** critical section, with a conditional retain: `calogFnRetainIfLive`
|
||||
(src/value.c:344) is a CAS loop that refuses to bump a count already at zero, so an entry mid-finalize
|
||||
is left out of the sweep and its own finalize frees it. Evaluating the refcount under that lock is
|
||||
sound because every path that frees a shell untracks first, and untrack needs the same mutex -- while
|
||||
the sweep holds it, no shell can go away underneath the CAS.
|
||||
|
||||
*Verifier:* Raised by an adversarial reviewer against the S2 implementation. A deterministic test for
|
||||
a window this narrow is not practical, so `tests/testTeardown.c` churns it: 20 rounds of a JavaScript
|
||||
context arming a 1 ms repeating timer, a subscriber and an export, closed while the timer thread is
|
||||
still retaining, invoking and releasing its callback. That test then found S6 and S7.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM (6)
|
||||
|
||||
### S9. [bug] src/value.c:325 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**`actorReleaseCallable`'s fallback ran an engine release on the wrong thread when the owner was alive -- heap corruption inside a running VM, reached under memory pressure.**
|
||||
|
||||
The fallback fired for two very different reasons and treated them alike: the owner is *gone* (fine --
|
||||
`calogFnFinalize` then only frees memory), or the `calloc` for the release message failed while the
|
||||
owner is very much *alive*, in which case `calogContextRegistered` returns true and the adapter's
|
||||
interpreter op (`luaL_unref`, `JS_FreeValue`, ...) runs from a thread that does not own that
|
||||
interpreter. Fixed with `calogFnFinalizeForeign`: free the memory, deliberately leak the handle inside
|
||||
the interpreter. A leak on an OOM path beats corrupting a VM that is still running.
|
||||
|
||||
*Verifier:* An adversarial reviewer caught a **blocker in the first version of this fix before it
|
||||
shipped**: it freed `userData` unconditionally, but a host-owned callable from `calogFnFromNative`
|
||||
has no release hook and a `userData` the *embedder* owns -- freeing that would corrupt their heap,
|
||||
worse than the corruption being prevented. It now frees on exactly `calogFnFinalize`'s condition.
|
||||
|
||||
---
|
||||
|
||||
### S10. [bug] vendor/ourbasic/ourBasic.c:1544 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**my-basic classified a symbol as a number from `strtoll`'s `endptr` alone, which is not portable: on musl every operator became the integer 0.**
|
||||
|
||||
The test was "did the conversion reach the string terminator". **musl's `strtoll` advances `endptr`
|
||||
past leading whitespace and a sign even when no conversion happens**, where glibc leaves it at the
|
||||
start (`strtoll("+")`: glibc consumed=0, musl consumed=1 with `*end == '\0'`). So on musl `+`, `-`
|
||||
(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" -- 13 of 20 checks in `testEngineMyBasic`. `strtod` does **not** have the quirk;
|
||||
only `strtoll`. Fixed with `_conv_matched_nothing`, which 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` keep their behaviour.
|
||||
|
||||
*Verifier:* Narrowed by elimination rather than guessed: identical sources pass under native gcc,
|
||||
native clang `-O2`, **and zig targeting glibc**, isolating musl as the variable; then reproduced in a
|
||||
**pure my-basic program containing no calog code at all** (`x = 1` returns `MB_FUNC_ERR` on musl,
|
||||
`MB_FUNC_OK` on glibc). 20/20 on musl after the fix, unchanged natively. See
|
||||
`vendor/ourbasic/CHANGELOG`.
|
||||
|
||||
---
|
||||
|
||||
### S11. [bug] tools/crossDeps.sh:637 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**An ABI-visible define reached the native build but not the cross build: the Windows and macOS full CLIs had not been buildable since sandbox parity landed.**
|
||||
|
||||
`MRB_USE_DEBUG_HOOK` enables the per-instruction hook the wall-clock budget needs, 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, and `crossWinFull.sh` stopped at
|
||||
`mrubyAdapter.c: no member named 'code_fetch_hook' in 'struct mrb_state'`. 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. Fixed in the generated cross config and in both full-CLI
|
||||
adapter compiles (tools/crossWinFull.sh:81), with mruby rebuilt for all three targets.
|
||||
|
||||
*Verifier:* All three full CLIs now build -- `calog.exe` PE32+ importing only Windows system DLLs,
|
||||
and Mach-O for x86_64 and arm64 -- and were confirmed to carry this session's changes rather than
|
||||
being stale, by checking the abort message and the refactored format string in the binaries.
|
||||
|
||||
---
|
||||
|
||||
### S12. [bug] tools/crossBuild.sh:66 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**The cross harness counted a failing test as a pass -- and it had already hidden a real failure.**
|
||||
|
||||
`mrun` reported `[musl RAN, nonzero]` and incremented `pass`, justified by a comment claiming "some
|
||||
tests exit nonzero by design". None of the eleven do; all return 0 only when every check passed. That
|
||||
leniency is precisely how `testEngineMyBasic` reported success on musl while 13 of its 20 checks were
|
||||
failing (S10). The Squirrel case was worse still: it ran its binary and **discarded the exit code
|
||||
entirely**, printing `[musl RUN ok]` unconditionally, so a Squirrel regression could never have been
|
||||
reported. Fixed by collapsing to one strict runner -- nonzero is a failure, `timeout` bounds a hang,
|
||||
and the binary's last line (its check counts) is echoed -- with Squirrel folded into it and no lenient
|
||||
variant left to drift back to.
|
||||
|
||||
*Verifier:* Teeth confirmed by driving the runner with a stub compiler emitting a binary that prints
|
||||
`20 checks, 13 failed` and exits 1 -- exactly the shape it used to wave through -- and observing
|
||||
`pass=1 fail=1`. The matrix now reports real counts: 151 checks across 12 musl binaries.
|
||||
`tools/crossArchive.sh` was audited for the same pattern and is already strict.
|
||||
|
||||
---
|
||||
|
||||
### S13. [bug] tools/crossDeps.sh (build_libarchive) --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**Enabling a musl dep target let `libarchive` fall through into the macOS branch, which built a musl artifact with an Apple SDK header and a Darwin link stub -- and reported success.**
|
||||
|
||||
`build_libarchive` branches win / else-macOS. With musl added to the default dep list it took the
|
||||
`else`, copying `iconv.h` out of the Apple SDK, passing `-DLIBICONV_PATH=tools/macStubs/libiconv.tbd`
|
||||
(a Darwin `.tbd`), and pointing `OPENSSL_SSL_LIBRARY` at `openssl-repack`, which is mac-only and does
|
||||
not exist for musl. It exited 0. Fixed with an explicit musl guard that refuses and names the right
|
||||
route (`tools/crossArchive.sh`, which handles musl's iconv gotcha), plus removing libarchive from the
|
||||
musl default list; the bogus artifacts were deleted.
|
||||
|
||||
*Verifier:* Caught by reading the build log rather than the exit status -- the line
|
||||
`[sdk] mac libarchive: ... -> xar ENABLED` appeared during a **musl** build. This is the same failure
|
||||
mode as S11: a cross build that succeeds while being wrong.
|
||||
|
||||
---
|
||||
|
||||
### S14. [bug] vendor/ourbasic/ourBasic.c:3639 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**`_get_priority` indexed the precedence table with a value that can be `-1`, and the bounds assert only checked the upper bound.**
|
||||
|
||||
`_get_priority_index` linearly searches a table of operator function pointers and returns `-1` when it
|
||||
finds none; `_get_priority` then evaluates `_PRECEDE_TABLE[idx1][idx2]`. The guarding `mb_assert`
|
||||
checks `idx < countof(...)` only, so a negative index is an out-of-bounds read off the front of a
|
||||
static array. calog defines no `NDEBUG`, so today the assert fires; an embedder building with
|
||||
`NDEBUG` compiles it away and reads out of bounds silently. Fixed by returning the table's own
|
||||
"cannot operate" marker (a space) for a negative index, which the evaluator already turns into a
|
||||
clean `SE_RN_FAILED_TO_OPERATE` script error -- the right answer for an unknown operator.
|
||||
|
||||
*Verifier:* Confirmed latent, not live: an instrumented build printing on the not-found path showed
|
||||
the lookup always resolves in current usage. Noticed while tracing S10, and closed while it was still
|
||||
theoretical.
|
||||
|
||||
---
|
||||
|
||||
## LOW (3)
|
||||
|
||||
### S15. [gap] tools/crossBuild.sh --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**`testNet` had no cross coverage on any target: its case predated `calogNet` gaining a TLS dependency.**
|
||||
|
||||
`libs/calogNet.c` has included `<openssl/bio.h>` since the tcp transport gained TLS, and the harness
|
||||
passed no OpenSSL flags, so the case failed to build on all four targets. The test itself uses no TLS
|
||||
-- only the library it links does -- so nothing was wrong with the coverage, just the link line. Fixed
|
||||
by linking the per-target OpenSSL `tools/crossDeps.sh` already produced for Windows and macOS, adding
|
||||
a **musl** target to that script, and skipping with the exact command to produce it when a target's
|
||||
OpenSSL is absent (a skip is counted separately, never as a pass).
|
||||
|
||||
*Verifier:* `testNet` builds on all four targets and **runs 10/10 statically on musl**, matching the
|
||||
native run. Coverage was extended well past the original gap: all ten engines now cross-build on four
|
||||
targets and run on musl, and TLS itself is exercised at runtime by `testHttps` (6/6, fully static),
|
||||
so the cross-built OpenSSL is no longer merely link-verified.
|
||||
|
||||
---
|
||||
|
||||
### S16. [process] Makefile:967 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**The cross-builds were not exercised by anything automatic, which is why S10, S11 and S15 sat unnoticed for weeks.**
|
||||
|
||||
Nothing else in the project can drift silently; the cross scripts could, and three regressions did.
|
||||
They cannot go into `make test` -- they take minutes and need a zig toolchain a native contributor
|
||||
does not have to install. Fixed with a family of `cross-*` targets that delegate to the existing
|
||||
scripts (`cross`, `cross-smoke`, `cross-win`, `cross-mac`, `cross-deps-*`, all gated behind an
|
||||
explicit `zig-check` that hard-errors rather than skipping), plus **`cross-lint`**: zig-free,
|
||||
sub-second, and 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 is missing from one of them -- the
|
||||
exact class that broke the Windows CLI.
|
||||
|
||||
*Verifier:* Teeth confirmed by running it with a fabricated define, which correctly fails; it passes
|
||||
on the current tree and runs inside every `make test`.
|
||||
|
||||
---
|
||||
|
||||
### S17. [design] libs/calogTask.c (taskExit) --- KEPT
|
||||
|
||||
**Status: KEPT**
|
||||
|
||||
**`taskExit` returns while `calogExit` does not, so two natives whose names both say "exit" differ on the property this session's original bug was about.**
|
||||
|
||||
`taskExit` calls `calogCurrentRetire`: it flags the context and the current chunk **runs to
|
||||
completion**. `API.md` has always documented that ("Deferred -- the current code finishes normally"),
|
||||
and it is enforced by the task tests, but a reader who learns "`calogExit` does not return" and
|
||||
applies the rule to `taskExit` gets the old footgun back. The overlap is real rather than theoretical:
|
||||
`calogCurrentRetire` works on any context and the task library is registered for every script, so a
|
||||
plain top-level script can call `taskExit()` and get the deferred behaviour.
|
||||
|
||||
**Kept deliberately.** Two options were put up -- rename to `taskRetire` so nothing named "exit"
|
||||
returns, or make it immediate -- and both were declined: the deferred behaviour is documented and
|
||||
useful (a task that should finish its current chunk then stop has no other spelling), and it is the
|
||||
only way a spawned task can end itself in a host that is not `bin/calog`, since the runner's natives
|
||||
do not exist there. The difference is documented on both `API.md` rows.
|
||||
|
||||
*Verifier:* Demonstrated rather than asserted: `taskExit()` in a top-level script prints the line
|
||||
after it and exits 0; the same script with the immediate primitive does not. Also confirmed that
|
||||
`taskExit` already gets everything the removed `calogEnd` provided except an exit code -- a lone
|
||||
script ending with it is reaped and ends the run.
|
||||
|
||||
---
|
||||
|
||||
## Second pass -- three open items closed (3)
|
||||
|
||||
Raised as "what still needs fixing" after the session above and fixed in the same session. S18 and
|
||||
S19 were both listed as **accepted limitations** in design.md sec 25; re-examining them showed one
|
||||
had never been true and the other had stopped being expensive.
|
||||
|
||||
---
|
||||
|
||||
### S18. [bug] src/calogMain.c:292 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**`Ctrl-C` could not stop a running script: the signal requested shutdown and then teardown sat waiting for scripts that had no reason to stop.**
|
||||
|
||||
`onSignal` set `gShutdown` and nothing else, on the documented reasoning that "an interrupt is not a
|
||||
script asking to stop". The effect was that a script doing real work ignored the interrupt entirely
|
||||
until it finished on its own -- `calogDestroy` had already committed to joining its thread. For a
|
||||
tool whose whole job is running someone else's script, an interrupt the script can outrun is not an
|
||||
interrupt. Fixed by having the handler record an abort request (an atomic store, the only thing a
|
||||
handler may safely do -- `calogAbortAll` is not async-signal-safe and the handler holds no runtime
|
||||
pointer) and having `main` latch the runtime as the pump loop drops out. That alone fixes every
|
||||
script that calls a native, because the dispatch choke point already refuses natives on a latched
|
||||
runtime.
|
||||
|
||||
A script that calls **nothing** needed the interpreters. The mechanism already existed -- the sandbox
|
||||
work put a per-instruction or heartbeat hook in nine of ten engines -- but each was installed only
|
||||
for a *limited* context, and nothing under `bin/calog` is limited, so none of them ever ran. They are
|
||||
now installed unconditionally and check the latch before any budget arithmetic, deliberately without
|
||||
retiring the context (a hook that closed its own context would strand what its registries hold --
|
||||
finding S2 from a new direction). Tcl, which has no periodic hook, re-arms a 100 ms time limit whose
|
||||
handler polls; Janet's watchdog thread now runs for every context. The Wren and Squirrel VM patches
|
||||
got *smaller* in the process: reason codes and the retire decision moved out of the vendored
|
||||
interpreter loops into the adapters, leaving no policy or magic number in vendored code and one
|
||||
definition of the abort message instead of three.
|
||||
|
||||
*Verifier:* Measured end to end against the shipped binary, not asserted: a native-calling loop and a
|
||||
native-free loop per engine, `SIGINT` after one second. Before: the native loop hung indefinitely
|
||||
(killed at 2 min). After: **nine of ten engines stop in ~1.0 s with exit 130 and empty stderr**;
|
||||
`s7`'s native-free loop is the sole exception and is documented, its only evaluation hook
|
||||
(`s7_set_begin_hook`) firing solely for the un-optimized `OP_BEGIN` form, so a `do` loop and a
|
||||
tail-recursive named `let` both run straight past it. That hook was implemented, measured, and
|
||||
**removed** -- see S19, which it broke. All twelve TSan targets clean afterwards, including Janet's
|
||||
new per-context watchdog thread.
|
||||
|
||||
---
|
||||
|
||||
### S19. [bug] src/js/jsAdapter.c:454, src/tcl/tclAdapter.c:850, src/mruby/mrubyAdapter.c:645, src/s7/s7Adapter.c:545 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**A script with a syntax error, handed to a runtime that was already aborting, was silently swallowed on four engines -- the same class of defect as S1: a failure that never surfaces.**
|
||||
|
||||
Every adapter guarded its eval-failure path with "is the runtime aborting", which is true whether the
|
||||
script failed on its own or we stopped it. Six engines got away with it because their API rejects a
|
||||
bad source before any code runs; the four whose eval API compiles and runs in one call (QuickJS
|
||||
`JS_Eval`, Tcl `Tcl_EvalEx`, mruby `mrb_load_string`, s7's catch wrapper) could not tell the two
|
||||
apart and reported nothing at all. A script author whose script is broken is told nothing about it --
|
||||
in a build tool, the worst possible failure mode. Fixed by asking the exact question instead: a
|
||||
context is marked at the three -- and only three -- places an abort can enter running script code
|
||||
(`calogAbortAll`, the dispatch choke point, an engine hook unwinding a native-free loop), and
|
||||
`calogAbortRaised` reads that mark. Exact whether or not an engine can separate parsing from running,
|
||||
so it replaced the old query in **all ten** adapters -- one concept, with the six that were right by
|
||||
accident of their API now right on purpose.
|
||||
|
||||
*Verifier:* A per-engine case was added to `tests/testExit.c` that latches the runtime **first**, then
|
||||
hands the engine a source it can only reject. Teeth confirmed by reverting the four adapters in place
|
||||
to the old query: the check failed on exactly QuickJS, Tcl, mruby and s7 and on nothing else, which
|
||||
simultaneously confirmed the other six were already correct. One engine cannot be fixed and the test
|
||||
says so per engine rather than hiding it: **my-basic** has no meaningful parse step (`mb_load_string`
|
||||
accepts `IF IF IF`; the error appears only when the statement runs), so its step hook unwinds a
|
||||
broken script before it can report. On an engine with no separate parse the two goals are in direct
|
||||
conflict, and the interrupt wins because a runaway loop is the commoner problem. That trade is also
|
||||
what settled s7 in S18 -- keeping its partial begin hook would have moved s7 into the same bucket,
|
||||
trading a diagnostic that worked for an interrupt that only sometimes did.
|
||||
|
||||
---
|
||||
|
||||
### S20. [dead-code] src/calog.h:278 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**`calogAbortCurrent` was public API with no caller anywhere in the product -- its only consumer was the test written to exercise it.**
|
||||
|
||||
Added as the per-context counterpart to `calogAbortAll`. No library native called it, the runner did
|
||||
not call it, and `taskExit` -- the one thing that ends a single script -- uses the deferred
|
||||
`calogCurrentRetire` instead and was deliberately left that way (S17). A public function whose sole
|
||||
caller is its own test is not evidence of a need, and `calog.h` is a much harder place to remove
|
||||
something from than to add to, since removal is only cheap while no embedder depends on it. Removed,
|
||||
together with the per-context `aborting` flag it set and the widening of `calogAborting` that read it
|
||||
-- so the question ten adapters ask is once again the simple one. `tests/testTeardown.c` drives the
|
||||
same early-context-death reclaim path through `calogCurrentRetire`, which is what actually ships.
|
||||
|
||||
*Verifier:* Confirmed unused by grep across `src/`, `libs/`, `tests/` and `examples/` before removal:
|
||||
the only hits were the declaration, the definition, the test native, and prose. After removal
|
||||
`make test` is **955 checks / 0 failed** with the reclaim coverage intact -- the teardown cases still
|
||||
exercise a context that ends itself while holding a subscriber, an export and a timer callback.
|
||||
|
||||
---
|
||||
|
||||
## Third pass -- the doors around the broker (3)
|
||||
|
||||
Found by a second review sweep (six lenses, adversarially verified) and fixed in the same session.
|
||||
All three are the same shape: a capability reaching the host without passing the native-dispatch
|
||||
choke point, which is the only place calog's allow-list, memory cap and wall-clock budget exist.
|
||||
|
||||
---
|
||||
|
||||
### S21. [security] libs/calogTask.c:344, libs/calogTask.c:388 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**`taskSpawn`/`taskLoad` opened UNLIMITED contexts, so one line of script escaped the memory cap, the wall-clock budget and the allow-list at once.**
|
||||
|
||||
Both called the non-limited constructors, and nothing propagated the caller's policy: the child got
|
||||
`memCap == 0`, `deadlineMs == 0` and `allow == NULL` regardless of the sandbox its parent ran under.
|
||||
Nothing in `calog.h`, `API.md` or `design.md` warned about it. Fixed with a policy object that is
|
||||
**shared** rather than copied -- a child points at the same `SandboxT` as its parent -- which yields
|
||||
all three inheritances from one decision: one memory pool for the tree (`memUsed` became `_Atomic`,
|
||||
turning every adapter's existing `+=` into an atomic op with no adapter change), one absolute
|
||||
deadline (inheriting the *duration* would let a chain of spawns walk the budget forward forever), and
|
||||
the same allow-list. Inheriting those three is still not sufficient -- `while true do taskSpawn(...)
|
||||
end` stays inside all of them and exhausts the host's threads -- so `CalogLimitsT` gained
|
||||
`maxContexts`, enforced with a CAS on the shared live count so two scripts spawning at once cannot
|
||||
both slip past. That count is also the reference count, so the policy outlives any single context in
|
||||
its tree.
|
||||
|
||||
*Verifier:* Reported with two built probes: a child allocating ~200 MB and still running 15x past its
|
||||
parent's deadline, and a forbidden native running via a spawned child. Four regression cases added to
|
||||
`tests/testSandbox.c` (shared pool, inherited allow-list, inherited deadline, `maxContexts` refusal).
|
||||
Teeth confirmed the hard way -- reverting the two call sites to the non-inheriting opens turned the
|
||||
memory case into an actual unbounded memory bomb that hung the test binary, which is precisely the
|
||||
escape. `bin/calog` itself never sandboxes, so the shipped runner was never exposed; the hole needed
|
||||
an embedder that both registers `calogTask` and opens a limited context.
|
||||
|
||||
---
|
||||
|
||||
### S22. [security] src/lua/luaAdapter.c:231 (and five other adapters) --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**Every engine that ships an operating-system interface handed scripts a way around the broker entirely -- including arbitrary native code execution and killing the host process.**
|
||||
|
||||
The allow-list gates calog's own natives at one dispatch point; an engine's own bindings never reach
|
||||
it. A survey of all ten engines, with every finding reproduced by running it, found this was not
|
||||
theoretical. Lua: `package.loadlib` dlopened a purpose-built `.so` and executed native code inside
|
||||
the calog process, `os.exit(77)` terminated the host mid-run, `io.open`/`os.execute`/`io.popen`/
|
||||
`os.getenv` were all open, and `dofile` executed arbitrary files. Tcl: `exec`, `open |cmd`, `socket`,
|
||||
`load`, `exit`, `glob`, `source`, `cd`, `::env`. s7: `(system ...)`, `exit`, `emergency-exit`,
|
||||
`abort`, the file ports, `load`, `getenv`. Janet: `ffi/` (attacker-supplied machine code), `os/exit`,
|
||||
`file/open`, `os/getenv`. Berry: `os.system`, `os.exit`, `open`, `.so` import. my-basic:
|
||||
`IMPORT "path"` read an arbitrary file and executed it as BASIC. Squirrel and mruby needed nothing.
|
||||
|
||||
Fixed at interpreter creation on all six affected engines, by whichever mechanism that engine
|
||||
actually supports: an explicit `luaL_requiref` allow-list instead of `luaL_openlibs` (dropping the
|
||||
library beats hiding the global -- it never enters `LOADED`, so nothing can `require` it back);
|
||||
`janet_sandbox()` plus two hand-removed bindings it does not guard; a Tcl seal script; s7 build flags
|
||||
plus a vendored patch; Berry config macros plus one refusing function; and a parser-side refusal in
|
||||
calog's my-basic fork. Applied to every context, not only sandboxed ones -- a built-in that ends the
|
||||
host process is wrong in an ordinary run too.
|
||||
|
||||
*Verifier:* Every removal re-tested per engine against `bin/calog` with a probe script, and each
|
||||
engine's shipped example re-run to confirm nothing legitimate broke. Two subtleties were caught by
|
||||
testing rather than reading, and both would have left a hole in a "done" feature: deleting Tcl's
|
||||
`file` command leaves the 37 `::tcl::file::*` implementation commands callable by fully-qualified
|
||||
name, and unregistering Tcl's standard channels does not stop `chan puts stdout`, because
|
||||
`Tcl_GetChannel` re-resolves the *names* through `Tcl_GetStdChannel`. s7 additionally cannot be fixed
|
||||
from the adapter at all -- `unlet`/`#_name` recover the original binding of anything a script
|
||||
rebinds. `make test` 959 checks / 0 failed, 38 examples pass, all TSan targets clean, cross-build
|
||||
39 ok / 0 failed with the vendored patches.
|
||||
|
||||
---
|
||||
|
||||
### S23. [bug] Makefile:677, Makefile:683 --- FIXED
|
||||
|
||||
**Status: FIXED**
|
||||
|
||||
**`make static` could not link, and with system OpenSSL headers present it silently compiled against the wrong ones first.**
|
||||
|
||||
`libs/calogNet.c` has included `<openssl/ssl.h>` since the tcp transport gained TLS, but the
|
||||
`obj/rel/%.o` rule passed no `$(OSSLINC)` and `bin/calogStatic` linked no `$(SSLARCH)` -- 24
|
||||
undefined references. The near-miss is the more interesting half: on a host with system OpenSSL
|
||||
headers installed it compiled `calogNet.c` against THOSE while linking the vendored archives, so a
|
||||
header/library skew was possible in a target documented as supported and verified. This is the same
|
||||
defect already found and fixed once in `tools/crossBuild.sh` (S15); the sibling Makefile path was
|
||||
missed because nothing built it, and `cross-lint` cannot catch it (it only inspects the two
|
||||
full-CLI cross scripts).
|
||||
|
||||
*Verifier:* Reproduced (`undefined reference to SSL_free` ...), fixed by mirroring the native rule
|
||||
and the `bin/testNet` link line, and confirmed: `bin/calogStatic` is fully static, has no dynamic
|
||||
dependencies, and runs. `static` is now a prerequisite of `make test` so this cannot rot a third
|
||||
time -- it costs a handful of -O2 objects and a link, and OpenSSL was already required by
|
||||
`bin/testNet`.
|
||||
|
||||
---
|
||||
|
|
|
|||
20
Makefile
20
Makefile
|
|
@ -129,7 +129,11 @@ BERRYOBJ = $(foreach f,$(BERRYSRC),obj/$(notdir $(f:.c=)).o)
|
|||
# feature config; s7 int is 64-bit and strings are binary-safe. ---
|
||||
S7DIR = vendor/s7
|
||||
S7INC = -I$(S7DIR)
|
||||
S7FLAGS = -std=c99 -w -g -O1 -D_GNU_SOURCE
|
||||
# WITH_SYSTEM_EXTRAS=0 removes (system ...), file-exists?, delete-file, directory->list and
|
||||
# getdirandfile; WITH_C_LOADER=0 removes (load "x.so") dlopen'ing native code into this process;
|
||||
# WITH_R7RS=0 is what actually removes getenv (s7 gates it on r7rs OR system-extras). None of
|
||||
# these can be closed from the adapter: s7's unlet/#_ recover any rebinding of a built-in.
|
||||
S7FLAGS = -std=c99 -w -g -O1 -D_GNU_SOURCE -DWITH_SYSTEM_EXTRAS=0 -DWITH_C_LOADER=0 -DWITH_R7RS=0
|
||||
S7OBJ = obj/s7.o
|
||||
S7LIBS = $(DLLIB) -lm
|
||||
|
||||
|
|
@ -674,13 +678,13 @@ obj/rel:
|
|||
mkdir -p obj/rel
|
||||
|
||||
$(RELOBJ): obj/rel/%.o: %.c | obj/rel
|
||||
$(CC) $(RELFLAGS) $(INC) $(LUAINC) $(SQLITEINC) $(ENETINC) -DCALOG_WITH_SQLITE -pthread -c -o $@ $<
|
||||
$(CC) $(RELFLAGS) $(INC) $(LUAINC) $(SQLITEINC) $(ENETINC) $(OSSLINC) -DCALOG_WITH_SQLITE -pthread -c -o $@ $<
|
||||
|
||||
obj/rel/staticDemo.o: examples/staticDemo.c src/calog.h libs/calogDb.h libs/calogNet.h | obj/rel
|
||||
$(CC) $(RELFLAGS) $(INC) -pthread -c -o $@ $<
|
||||
|
||||
bin/calogStatic: obj/rel/staticDemo.o $(RELOBJ) lib/liblua.a lib/libsqlite3.a lib/libenet.a | bin
|
||||
$(CC) -static -pthread -o $@ $^ $(LUALIBS) $(SQLITELIBS)
|
||||
bin/calogStatic: obj/rel/staticDemo.o $(RELOBJ) lib/liblua.a lib/libsqlite3.a lib/libenet.a $(SSLARCH) | bin
|
||||
$(CC) -static -pthread -o $@ $(filter-out $(SSLARCH),$^) $(LUALIBS) $(SQLITELIBS) $(SSLARCH) $(DLLIB)
|
||||
|
||||
.PHONY: static
|
||||
static: bin/calogStatic
|
||||
|
|
@ -738,7 +742,7 @@ bin/testArchive: obj/testArchive.o obj/calogArchive.o obj/calogHandle.o lib/libc
|
|||
bin/testUtil: obj/testUtil.o obj/calogCsv.o obj/calogProc.o obj/calogRegex.o lib/libcalog.a lib/liblua.a $(PCRE2LIB) | bin
|
||||
$(CC) $(LDFLAGS) -pthread -o $@ $^ $(LUALIBS)
|
||||
|
||||
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
|
||||
bin/testSandbox: obj/testSandbox.o $(TASKADP) obj/calogHandle.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.
|
||||
|
|
@ -774,7 +778,11 @@ ssh-test: bin/testSsh
|
|||
obj bin lib:
|
||||
mkdir -p $@
|
||||
|
||||
test: all cross-lint
|
||||
# `static` is built here, not just offered as a target: the fully-static path went unbuildable for
|
||||
# weeks (calogNet gained a TLS dependency the obj/rel rule never got) precisely because nothing
|
||||
# exercised it. It costs a handful of -O2 objects and a link -- the vendored archives are shared with
|
||||
# the rest of the tree, and OpenSSL is already required by bin/testNet.
|
||||
test: all cross-lint static
|
||||
./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/testExit && ./bin/testTeardown && ./bin/testTrace && ./bin/testHttpdLua
|
||||
|
|
|
|||
|
|
@ -217,9 +217,12 @@ 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.
|
||||
untrusted cert, and the pinned-trust path -- all through the cross-built OpenSSL.
|
||||
- **Every musl case is run strictly**: a nonzero exit is a failure, and the binary's last line (its
|
||||
check counts) is echoed. The harness used to count a nonzero exit as a pass -- "some tests exit
|
||||
nonzero by design", which none of them do -- and the Squirrel case discarded its exit code
|
||||
entirely. That leniency is how `testEngineMyBasic` reported success on musl while 13 of its 20
|
||||
checks were failing. There is one runner now, with no lenient variant to drift back to.
|
||||
- **`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
|
||||
|
|
|
|||
|
|
@ -150,8 +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 (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
|
||||
everything down (calog is event-driven, so a script asks to stop). It does
|
||||
not return -- the statement after it never runs, so a build script's `calogExit(1)` is final.
|
||||
`Ctrl-C` stops a run the same way: it aborts the running scripts rather than waiting for them, so
|
||||
even a script stuck in a loop that calls nothing gives up promptly (on every engine but s7). 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
|
||||
|
|
@ -416,7 +418,6 @@ CalogT *calogCurrent(void);
|
|||
|
||||
// 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
|
||||
```
|
||||
|
||||
|
|
|
|||
294
design.md
294
design.md
|
|
@ -1483,19 +1483,12 @@ 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.
|
||||
- **A script that deliberately catches the unwind is not preempted between hook ticks** -- the same
|
||||
cooperative-model limit as the sandbox (sec 24). It is stopped at its next native call or its
|
||||
engine's next interpreter hook, whichever comes first.
|
||||
|
||||
Two limitations listed here originally -- that a signal could not abort scripts, and that four
|
||||
engines swallowed a syntax error along with the abort -- were closed later; see sec 29.
|
||||
|
||||
### Test
|
||||
|
||||
|
|
@ -1633,16 +1626,15 @@ 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.
|
||||
|
||||
### Ending one script -- `calogAbortCurrent`, and the runner native that was not needed
|
||||
### Ending one script -- the API that was not needed either
|
||||
|
||||
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.
|
||||
With reclamation in place a context can safely end itself, so the core briefly gained
|
||||
`calogAbortCurrent`: the same unwind as `calogAbortAll`, scoped to one context. It was **removed
|
||||
again** in the same pass that removed `calogEnd` (see sec 29), for the same reason and with the same
|
||||
evidence: nothing in the product ever called it. Its only caller was the test written to exercise it,
|
||||
which is circular, and public API is far harder to withdraw once an embedder depends on it. Widening
|
||||
`calogAborting` to answer "the runtime latch OR this context" went with it, so the question ten
|
||||
adapters ask is once again the simple one.
|
||||
|
||||
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:
|
||||
|
|
@ -1665,16 +1657,16 @@ So the runner keeps two verbs and the library keeps its own:
|
|||
| `error(...)` | this script | 1 (the run did not fully succeed) |
|
||||
| `calogExit([code])` | everything, immediately | `code` (default 0) |
|
||||
|
||||
`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.
|
||||
What ends one script is therefore `taskExit` (deferred, and the only mechanism that ships), and
|
||||
`tests/testTeardown.c` drives the early-context-death reclaim path through `calogCurrentRetire` --
|
||||
the same call `taskExit` makes -- rather than through an API nothing else used.
|
||||
|
||||
### 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 `calogAbortCurrent`; a closure handed to a Lua script
|
||||
first; a script that ends itself; 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.
|
||||
|
||||
|
|
@ -1811,6 +1803,44 @@ window deterministically: a per-context shutdown hook runs inside it (after `ser
|
|||
and passes after -- the test has teeth, which for a window this narrow is the only evidence worth
|
||||
having.
|
||||
|
||||
### The sweep that could adopt a corpse
|
||||
|
||||
Sections 26's reclaim sweep took the owned-callable list under `queueMutex`, unlocked, and only then
|
||||
retained each entry. A foreign thread dropping the last reference in that gap leaves the sweep
|
||||
retaining a callable whose `calogFnRelease` has already committed to finalizing it -- reviving a
|
||||
corpse, and freeing it twice when the sweep drops its own reference.
|
||||
|
||||
The fix is to make taking the list and taking the references **one** critical section, with a
|
||||
conditional retain: `calogFnRetainIfLive` is a CAS loop that refuses to bump a count that has already
|
||||
reached zero, so an entry already being finalized is simply left out of the sweep and its own
|
||||
finalize frees it, as it was going to. That is safe to evaluate under the lock because every path
|
||||
that frees a shell untracks first, and untrack needs that same mutex -- so while the sweep holds it,
|
||||
no shell can go away underneath the CAS.
|
||||
|
||||
Writing a deterministic test for a window this narrow is not practical, so `tests/testTeardown.c`
|
||||
churns it instead: twenty rounds of a JavaScript context arming a 1 ms repeating timer, a subscriber
|
||||
and an export, then being closed while the timer thread is still retaining, invoking and releasing
|
||||
its callback. That test immediately earned its keep -- under ThreadSanitizer it found two more
|
||||
defects that no amount of ASan running had:
|
||||
|
||||
**A mutex used after it was destroyed.** `calogActorShutdown` destroyed `ctxMutex`, but the
|
||||
after-context destroy hooks run *later*, in `calogDestroy`, and releasing what those registries hold
|
||||
finalizes callables -- which lock `ctxMutex` to untrack and to resolve the owner. So pubsub's own
|
||||
shutdown locked a destroyed mutex, every time, deterministically. (Half of that predates the callable
|
||||
tracking: `calogContextRegistered` has always locked it there.) `ctxMutex` is now destroyed at the
|
||||
end of `calogDestroy`, after those hooks; by then every slot is gone, so the late locks find nothing,
|
||||
which is the answer they want.
|
||||
|
||||
**A data race on the callable's own fields.** `calogFnReclaim` used to null `release` and `userData`
|
||||
after running the engine release, to stop a later finalize repeating it. But those fields are read by
|
||||
`actorInvokeCallable` on *other* threads -- the timer thread was reading `userData` to marshal a call
|
||||
at the moment the owner nulled it. The `alive` flag does not close that: the invoke checks it before
|
||||
reading the fields. So reclaim now changes **nothing** except one atomic: a `reclaimed` flag claimed
|
||||
with `atomic_exchange`, so the engine release runs exactly once whether reclaim or a finalize gets
|
||||
there first, while `fn`/`userData`/`release` stay write-once-at-create and are safe to read from any
|
||||
thread. An in-flight marshal can still carry a dead callable's fields to the dispatch layer, where it
|
||||
is refused -- the queue was closed before the sweep ran (above), which is what makes that safe.
|
||||
|
||||
### An index that could go negative
|
||||
|
||||
`_get_priority_index` (my-basic) linearly searches a table of operator function pointers and returns
|
||||
|
|
@ -1823,3 +1853,215 @@ evaluator turns it into a clean `SE_RN_FAILED_TO_OPERATE` script error. So a neg
|
|||
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.)
|
||||
|
||||
---
|
||||
|
||||
## 29. Interrupts that interrupt, and diagnostics that survive them
|
||||
|
||||
Three things were closed here. Two of them were listed as accepted limitations of sec 25 and turned
|
||||
out not to deserve the status; the third was public API that nothing used.
|
||||
|
||||
### `Ctrl-C` that stops a script
|
||||
|
||||
`SIGINT`/`SIGTERM` only ever set `gShutdown`, so the pump loop dropped out and `calogDestroy` went to
|
||||
join the context threads -- threads still running script. A script doing real work therefore ignored
|
||||
`Ctrl-C` entirely until it finished on its own. On a build script that means the interrupt does
|
||||
nothing, which is the opposite of what an interrupt is for.
|
||||
|
||||
The handler now also records an abort request, and `main` latches the runtime the moment the pump
|
||||
loop drops out. The latching is not done in the handler: `calogAbortAll` is not async-signal-safe and
|
||||
the handler has no runtime pointer, so the handler does the one thing it may -- an atomic store --
|
||||
and `abortIfSignalled` consumes it. That single change is enough for every script that calls a
|
||||
native, because the dispatch choke point (sec 25) already refuses natives on a latched runtime. A
|
||||
loop that sleeps, prints, reads a socket or touches a database stops at its next call.
|
||||
|
||||
What it does not reach is a script that calls *nothing*: a bare `while true do end` never passes
|
||||
through `calogCall`, so nothing can see the latch on its behalf. That needed the interpreters, and
|
||||
the mechanism was already built -- the sandbox work of sec 24 put a per-instruction or heartbeat hook
|
||||
in nine of the ten engines to enforce the wall-clock budget. Those hooks were installed **only for a
|
||||
limited context**, which is exactly why they were no help here: nothing under `bin/calog` is limited.
|
||||
They are now installed unconditionally and check the abort latch first, before any budget arithmetic:
|
||||
|
||||
| engine | hook | abort reaches a native-free loop |
|
||||
|---|---|---|
|
||||
| Lua | `lua_sethook`, every 1000 instructions | yes |
|
||||
| JavaScript | `JS_SetInterruptHandler` | yes |
|
||||
| Squirrel | `Execute` loop poll, every 1024 opcodes | yes |
|
||||
| my-basic | per-statement stepped handler | yes |
|
||||
| Berry | `be_set_obs_hook` VM heartbeat | yes |
|
||||
| Wren | bytecode `LOOP` back-jump | yes |
|
||||
| mruby | `code_fetch_hook`, amortized | yes |
|
||||
| Tcl | time-limit handler, re-armed every 100 ms | yes |
|
||||
| Janet | watchdog thread, 5 ms poll | yes |
|
||||
| s7 | -- | **no** |
|
||||
|
||||
The hooks deliberately do **not** retire the context the way a budget overrun does. `calogAbortAll`
|
||||
leaves teardown order to the host (sec 25), and a context that closed itself from a hook would
|
||||
destroy its own interpreter while the registries still held its callables -- the sec 26 crash, from a
|
||||
new direction.
|
||||
|
||||
Three of these were worth a note. **Tcl** has no periodic hook at all, only a limit handler that
|
||||
fires when a limit is *exceeded*; so every Tcl context now arms a 100 ms time limit whose handler
|
||||
re-arms itself, making a limit mechanism into a poll. A real budget still wins, and never re-arms
|
||||
further out than one poll interval, so a long budget cannot go quiet. **Janet** has no in-loop hook
|
||||
either, so its watchdog thread -- previously started only for limited contexts -- now runs for all of
|
||||
them; it interrupts from a foreign thread, which is why Janet alone still asks `calogAborting` rather
|
||||
than `calogAbortRaised` below (the marker is thread-local to the context being marked). **Wren** and
|
||||
**Squirrel** enforce from patched VM loops, and both patches got smaller: the reason codes and the
|
||||
retire decision moved out of the vendored file into the adapter, which now hands back the message to
|
||||
raise. The vendored loops no longer contain a policy or a magic number, and the abort text has one
|
||||
definition instead of three.
|
||||
|
||||
s7 is the exception, and its own architecture is the reason. Its only evaluation hook,
|
||||
`s7_set_begin_hook`, fires at the start of a `begin` block -- but only for the un-optimized `OP_BEGIN`
|
||||
form, so `(do ((i 0 (+ i 1))) (#f))` and a tail-recursive named `let` both run straight past it. It
|
||||
was implemented, measured, and **removed**: partial interruption that works for some loop shapes and
|
||||
not others is worse than a documented "no", and it cost something real (see below). s7 remains what
|
||||
it already was in sec 24 -- the engine with no usable VM hook -- and a native-free s7 loop is still
|
||||
only ended by killing the process.
|
||||
|
||||
### Diagnostics the abort must not eat
|
||||
|
||||
Sec 25 listed four engines that "cannot tell a compile failure from a run failure", so a script with
|
||||
a syntax error handed to an already-latched runtime had its diagnostic swallowed along with the
|
||||
abort. QuickJS, Tcl, mruby and s7 all compile and run in one API call, and every adapter was asking
|
||||
the same question: *is the runtime aborting?* -- which is true whether the script failed on its own or
|
||||
we stopped it.
|
||||
|
||||
The right question is *did WE put this error here*, and it is now asked directly. A context carries a
|
||||
flag set at the three -- and only three -- places an abort can enter running script code:
|
||||
`calogAbortAll`, the dispatch choke point that refuses every later native, and an engine hook
|
||||
unwinding a native-free loop. `calogAbortRaised` reads it. That is exact regardless of whether an
|
||||
engine can separate parsing from running, so it replaced the old question in all ten adapters rather
|
||||
than only the four that were wrong -- one concept, and the six that were already correct by accident
|
||||
of their API are now correct on purpose.
|
||||
|
||||
One engine cannot benefit, and it is worth being precise about why. **my-basic** has no meaningful
|
||||
parse step: `mb_load_string` accepts `IF IF IF` and the error appears only when the statement *runs*.
|
||||
So on my-basic a broken script handed to a latched runtime is unwound by the step hook before it can
|
||||
say what was wrong with it. The two goals are in direct conflict on an engine with no separate parse,
|
||||
and the hook wins because a runaway loop is the more common problem. `tests/testExit.c` asserts the
|
||||
asymmetry per engine rather than hiding it. This is also what settled s7: keeping its begin hook
|
||||
would have moved s7 into the same bucket, trading a diagnostic that worked for an interrupt that only
|
||||
sometimes did.
|
||||
|
||||
### `calogAbortCurrent`, removed
|
||||
|
||||
Added in sec 26 as the per-context counterpart to `calogAbortAll`. Nothing in the product ever called
|
||||
it -- not a library native, not the runner -- and its only caller was the test written to exercise it.
|
||||
A public function whose sole consumer is its own test is not evidence of a need, and `calog.h` is a
|
||||
much harder place to take something out of than to put it in. Removed, along with the per-context
|
||||
`aborting` flag it set and the widening of `calogAborting` that read it. The test now drives the same
|
||||
path through `calogCurrentRetire`, which is what `taskExit` calls and therefore what actually ships.
|
||||
|
||||
### Test
|
||||
|
||||
`tests/testExit.c` gained a case per engine: latch the runtime *first*, then hand the engine a source
|
||||
it can only reject, and require the diagnostic to survive (or, for my-basic, record that it cannot).
|
||||
The four adapters were reverted in place to confirm the check fails against the old question -- it
|
||||
failed on exactly QuickJS, Tcl, mruby and s7, and on nothing else, which is also what confirmed the
|
||||
other six were already right.
|
||||
|
||||
The interrupt itself is verified end to end against the shipped binary rather than in-process: a
|
||||
native-calling loop and a native-free loop per engine, `SIGINT` after one second, measured. Nine
|
||||
engines stop in about a second; s7's native-free loop is the one that does not, as the table says.
|
||||
|
||||
---
|
||||
|
||||
## 30. Closing the doors around the broker
|
||||
|
||||
Three holes, all the same shape: a capability that reached the host without passing the one place
|
||||
calog can see it. The allow-list, the memory cap and the wall-clock budget all live at the
|
||||
native-dispatch choke point, so anything that reaches the machine another way is not merely
|
||||
unrestricted -- it is invisible to every policy the API offers.
|
||||
|
||||
### A task no longer escapes its parent's sandbox
|
||||
|
||||
`taskSpawn` and `taskLoad` called `calogContextOpen`, the UNLIMITED constructor. A script under a
|
||||
2 MiB cap, a 200 ms budget and a strict allow-list could do its work in a child and have none of
|
||||
them: one line, all three limits gone. Proven with a probe -- a child allocated ~200 MB and was still
|
||||
running 15x past its parent's deadline, and a native the parent was denied ran happily in the child.
|
||||
|
||||
The fix is a policy object that is **shared**, not copied. A `SandboxT` holds the limit state, the
|
||||
allow-list and a live count; a context points at one, and a spawned child points at the SAME one.
|
||||
That single decision gives all three inheritances at once:
|
||||
|
||||
- **memory** -- one pool for the tree, because the child's allocator hook charges the same counter.
|
||||
`memUsed` became `_Atomic`, which turns every adapter's existing `+=`/`-=` into an atomic op with
|
||||
no adapter-side change at all. A check-then-charge pair is still two operations, so concurrent
|
||||
allocations can overshoot by at most one allocation per thread -- the same allocation-granular
|
||||
overshoot a single context already documents.
|
||||
- **time** -- the deadline was already resolved to an ABSOLUTE instant at open, so sharing it means
|
||||
the whole tree dies at one moment. Inheriting the *duration* instead would have let a chain of
|
||||
spawns walk the budget forward forever.
|
||||
- **allow-list** -- shared verbatim; a child cannot call what its parent may not.
|
||||
|
||||
Inheriting those three is still not enough, which is the part worth remembering: `while true do
|
||||
taskSpawn(...) end` stays inside every one of them and exhausts the host's threads. So `CalogLimitsT`
|
||||
gained a fourth field, `maxContexts`, checked with a CAS on the shared live count -- a bound two
|
||||
scripts spawning at once cannot both slip past. The count is also the reference count, so the policy
|
||||
outlives any single context in its tree; a parent that finishes first must not pull the budget out
|
||||
from under the children it started.
|
||||
|
||||
`calogContextOpenLimited` and the new internal `calogContextOpenInheriting` both funnel into one
|
||||
`contextOpenWithSandbox`, so the only difference between them is where the policy came from.
|
||||
|
||||
### The engines' own standard libraries
|
||||
|
||||
Every engine that ships an operating-system interface was handing scripts a way around the broker
|
||||
entirely. A survey of all ten, each finding reproduced by running it, found the damage was not
|
||||
theoretical: Lua's `package.loadlib` dlopened a purpose-built `.so` and executed native code inside
|
||||
the calog process; `os.exit(77)` terminated the host mid-run; Tcl's `exec`, `socket` and `load` did
|
||||
the same three things again; s7's `(system ...)` ran a shell and its file ports wrote anywhere;
|
||||
Janet's `ffi/` executed attacker-supplied machine code; Berry's `os.system` and `open` were wide
|
||||
open; and my-basic's `IMPORT "path"` read an arbitrary file and executed it as BASIC.
|
||||
|
||||
Six engines are now sealed at interpreter creation. Squirrel and mruby needed nothing -- they ship no
|
||||
host bindings. Wren and QuickJS keep only benign surface (writing to stdout, in-VM `Meta.eval`,
|
||||
clock/timezone reads).
|
||||
|
||||
The mechanism differs per engine because the engines do:
|
||||
|
||||
- **Lua** -- `luaL_openlibs` replaced by an explicit `luaL_requiref` list (base, coroutine, table,
|
||||
string, math, utf8). Dropping the libraries beats hiding the globals: the library is never inserted
|
||||
into `LOADED`, so nothing can `require` it back.
|
||||
- **Janet** -- `janet_sandbox()` is Janet's own mechanism and covers most of it. Two bindings are not
|
||||
guarded by it and had to go by hand: `os/exit` calls `exit()` with no assert, and `os/cwd` leaks
|
||||
the working directory.
|
||||
- **Tcl** -- a seal script rather than a list of `Tcl_DeleteCommand` calls, because deleting the
|
||||
visible command is NOT enough for an ensemble: after `rename file {}` the 37 `::tcl::file::*`
|
||||
implementation commands were still callable by fully-qualified name. Membership varies by build, so
|
||||
the namespaces are enumerated and swept. Unregistering the standard channels was likewise not
|
||||
enough on its own -- `Tcl_GetChannel` re-resolves the *names* `stdin`/`stdout`/`stderr` through
|
||||
`Tcl_GetStdChannel`, so `chan puts stdout` still reached the host until the channel commands went
|
||||
too.
|
||||
- **s7** -- the only engine where nothing can be done from the adapter: `unlet` and `#_name` recover
|
||||
the original binding of anything a script rebinds, so a shadowed `open-output-file` is no defence.
|
||||
Build flags remove some of it (`WITH_SYSTEM_EXTRAS=0`, `WITH_C_LOADER=0`, and `WITH_R7RS=0`, which
|
||||
is what actually removes `getenv`); the rest is a vendored patch making the C implementations
|
||||
refuse. Patching the two funnel points -- `open_input_file_1` and `s7_open_output_file` -- covers
|
||||
the whole file family, including `call-with-output-file`, which reaches the opener internally
|
||||
rather than through the symbol.
|
||||
- **Berry** -- mostly config macros (`BE_USE_OS_MODULE`, `BE_USE_SHARED_LIB`, the bytecode
|
||||
loader/saver, `BE_USE_INTROSPECT_MODULE`). `open` is named by a precompiled builtin table, so
|
||||
removing it would mean regenerating that table with `tools/coc`; the function is made to refuse
|
||||
instead, which is the same result without the codegen step.
|
||||
- **my-basic** -- the import happens at PARSE time, not run time (`_core_import` is a no-op), so the
|
||||
refusal goes in the parser's import branch. Module imports (`IMPORT "@name"`) are in-memory and
|
||||
stay.
|
||||
|
||||
Pure computation is untouched everywhere. What is gone is the ability to touch the machine without
|
||||
going through a native the host registered and can revoke.
|
||||
|
||||
### `make static` builds again
|
||||
|
||||
The fully-static path had been unbuildable since the tcp transport gained TLS: `libs/calogNet.c` has
|
||||
included `<openssl/*.h>` since then, but the `obj/rel` rule passed no `$(OSSLINC)` and
|
||||
`bin/calogStatic` linked no `$(SSLARCH)`. Worse than the plain failure was the near-miss -- with
|
||||
system OpenSSL headers installed it compiled against THOSE while linking the vendored archives.
|
||||
|
||||
The same defect had already been found and fixed once, in `tools/crossBuild.sh` (sec 27); the sibling
|
||||
Makefile path was missed because nothing built it. So `static` is now a prerequisite of `make test`.
|
||||
It costs a handful of -O2 objects and a link -- the vendored archives are shared with the rest of the
|
||||
tree and OpenSSL is already required by `bin/testNet` -- and it is the only thing that stops this
|
||||
particular rot recurring a third time.
|
||||
|
|
|
|||
|
|
@ -21,9 +21,10 @@ 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. `calogExit` does
|
||||
script asks to exit explicitly. `calogExit` does
|
||||
not return: the statement after it never runs, and the first code asked for is the one the
|
||||
process reports.
|
||||
process reports. `Ctrl-C` ends a run the same way, aborting the scripts rather than waiting
|
||||
for them to finish.
|
||||
- Extensions map to engines: `.lua .js .nut .bas .be .scm .wren`.
|
||||
|
||||
## `languages/` -- one guided tour per engine
|
||||
|
|
|
|||
|
|
@ -338,9 +338,12 @@ static int32_t taskLoad(CalogValueT *args, int32_t argCount, CalogValueT *result
|
|||
if (calog == NULL) {
|
||||
return calogFail(result, calogErrArgE, "taskLoad: must be called from a script");
|
||||
}
|
||||
context = calogContextLoad(calog, args[0].as.s.bytes);
|
||||
// Inheriting, not plain: a sandboxed script must not be able to step outside its policy by
|
||||
// loading its work into a child. The child shares the caller's memory pool, deadline and
|
||||
// allow-list, and the load is refused outright once the sandbox is at maxContexts.
|
||||
context = calogContextLoadInheriting(calog, args[0].as.s.bytes);
|
||||
if (context == NULL) {
|
||||
return calogFail(result, calogErrArgE, "taskLoad: no matching script file, or the load failed");
|
||||
return calogFail(result, calogErrArgE, "taskLoad: no matching script file, the load failed, or this sandbox is at its context limit");
|
||||
}
|
||||
handle = taskWrapContext(lib, context, result, "taskLoad");
|
||||
if (handle == 0) {
|
||||
|
|
@ -381,9 +384,10 @@ static int32_t taskSpawn(CalogValueT *args, int32_t argCount, CalogValueT *resul
|
|||
if (calog == NULL) {
|
||||
return calogFail(result, calogErrArgE, "taskSpawn: must be called from a script");
|
||||
}
|
||||
context = calogContextOpen(calog, engine);
|
||||
// Inheriting, not plain: see taskLoad. An unrestricted caller still gets an unrestricted child.
|
||||
context = calogContextOpenInheriting(calog, engine);
|
||||
if (context == NULL) {
|
||||
return calogFail(result, calogErrArgE, "taskSpawn: could not open a context");
|
||||
return calogFail(result, calogErrArgE, "taskSpawn: could not open a context, or this sandbox is at its context limit");
|
||||
}
|
||||
handle = taskWrapContext(lib, context, result, "taskSpawn");
|
||||
if (handle == 0) {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ struct CalogBerryT {
|
|||
// thread-local pointer -- set once at create -- resolves the running context exactly, the
|
||||
// same pattern my-basic uses for its process-global allocator (see mybasicAdapter.c).
|
||||
static _Thread_local CalogLimitStateT *gBerryLimits = NULL;
|
||||
// The broker of the Berry context running on THIS thread. The heartbeat hook gets only the bvm, so
|
||||
// this is how it reaches the runtime to ask whether the abort latch is set. Armed for every context,
|
||||
// limited or not, because an unlimited script must be interruptible too.
|
||||
static _Thread_local CalogT *gBerryBroker = NULL;
|
||||
|
||||
// Backs a CalogFnT exported from this VM: the owning context, the reclaimable slot
|
||||
// number, and the name of the hidden global that keeps the Berry function GC-reachable.
|
||||
|
|
@ -93,9 +97,8 @@ int32_t calogBerryCreate(CalogBerryT **out, CalogT *broker, uint64_t ctxId, Calo
|
|||
// vendored berry_conf.h) -- often enough to notice a wall-clock deadline inside a tight
|
||||
// loop. An unlimited context leaves the pointer NULL and installs no hook (zero overhead).
|
||||
gBerryLimits = limits;
|
||||
if (limits != NULL && limits->deadlineMs > 0) {
|
||||
be_set_obs_hook(context->vm, berryObsHook);
|
||||
}
|
||||
gBerryBroker = broker;
|
||||
be_set_obs_hook(context->vm, berryObsHook);
|
||||
*out = context;
|
||||
return calogOkE;
|
||||
}
|
||||
|
|
@ -128,6 +131,12 @@ static void berryObsHook(bvm *vm, int event, ...) {
|
|||
if (event != BE_OBS_VM_HEARTBEAT) {
|
||||
return;
|
||||
}
|
||||
// The runtime was latched aborting (calogExit, or a signal): unwind this script even though it
|
||||
// may never call a native. Deliberately no retire -- teardown order stays the host's.
|
||||
if (gBerryBroker != NULL && calogAborting(gBerryBroker)) {
|
||||
calogAbortRaise();
|
||||
be_raise(vm, "calog_abort", CALOG_ABORT_MESSAGE);
|
||||
}
|
||||
limits = gBerryLimits;
|
||||
if (limits != NULL && limits->deadlineMs != 0 && calogMonotonicMillis() >= limits->deadlineMs) {
|
||||
calogCurrentRetire();
|
||||
|
|
@ -214,6 +223,7 @@ void calogBerryDestroy(CalogBerryT *context) {
|
|||
// This thread's limit pointer aliases the context's limit state, which is freed with the
|
||||
// context; clear it so nothing on this (soon-joined) thread can read it afterward.
|
||||
gBerryLimits = NULL;
|
||||
gBerryBroker = NULL;
|
||||
// Release the foreign function values pushed into this VM (see berryTrackForeign).
|
||||
for (index = 0; index < context->foreignCount; index++) {
|
||||
calogFnRelease(context->foreignFns[index]);
|
||||
|
|
@ -507,10 +517,10 @@ int32_t calogBerryRun(CalogBerryT *context, const char *source) {
|
|||
}
|
||||
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)) {
|
||||
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, so report
|
||||
// nothing. Asked as "is this error ours", which can only be true of a script that
|
||||
// actually ran, so a syntax error still gets its diagnostic even mid-teardown.
|
||||
if (calogAbortRaised(context->broker)) {
|
||||
be_pop(vm, be_top(vm) - base);
|
||||
return calogOkE;
|
||||
}
|
||||
|
|
|
|||
10
src/broker.c
10
src/broker.c
|
|
@ -35,11 +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.
|
||||
// The caller has been stopped: the runtime was latched by calogAbortAll. 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. Marking
|
||||
// the context records that THIS error is ours, so the adapter knows to swallow it rather than
|
||||
// report it as the script's own failure.
|
||||
if (calogAborting(broker)) {
|
||||
calogAbortRaise();
|
||||
return calogFail(result, calogErrAbortE, CALOG_ABORT_MESSAGE);
|
||||
}
|
||||
entry = calogLookup(broker, name);
|
||||
|
|
|
|||
21
src/calog.h
21
src/calog.h
|
|
@ -239,8 +239,17 @@ typedef struct CalogLimitsT {
|
|||
int64_t memoryBytes; // 0 = unlimited (all engines but s7)
|
||||
int64_t wallClockMillis; // 0 = unlimited (all engines but s7)
|
||||
const char *const *allowList; // NULL = all natives permitted; else a NULL-terminated list of allowed names
|
||||
int32_t maxContexts; // 0 = unbounded; else the most contexts this sandbox may contain, counting the first
|
||||
} CalogLimitsT;
|
||||
|
||||
// A script that spawns a task (taskSpawn/taskLoad) does NOT escape its sandbox: the child SHARES
|
||||
// this policy rather than receiving a copy of it. One memory pool for the whole tree, one absolute
|
||||
// deadline (not a fresh budget per child), and the same allow-list. maxContexts bounds how many
|
||||
// contexts the tree may hold at once, which is what stops `while true do taskSpawn(...) end` from
|
||||
// exhausting the host's threads while staying inside the other three limits; a spawn past the bound
|
||||
// fails rather than being quietly granted. Leave it 0 and a sandboxed script can spawn without
|
||||
// limit -- appropriate only when the memory cap is what you are relying on.
|
||||
|
||||
// Like calogContextOpen, but the context enforces the given limits. limits may be NULL (unlimited,
|
||||
// identical to calogContextOpen). The limits are copied; the caller need not keep them.
|
||||
CalogContextT *calogContextOpenLimited(CalogT *calog, const CalogEngineT *engine, const CalogLimitsT *limits);
|
||||
|
|
@ -271,14 +280,10 @@ 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. 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.
|
||||
// True when the caller must stop running script code, because this runtime was latched by
|
||||
// calogAbortAll. An engine's interrupt hook asks this so a script that calls no natives at all --
|
||||
// a tight compute loop -- still stops. To ask whether a failure in hand WAS that abort rather than
|
||||
// the script's own, adapters use the internal calogAbortRaised instead.
|
||||
bool calogAborting(CalogT *calog);
|
||||
|
||||
extern const CalogEngineT calogLuaEngine;
|
||||
|
|
|
|||
|
|
@ -154,6 +154,15 @@ CalogEntryT *calogLookup(CalogT *calog, const char *name);
|
|||
// Safe only while the registry is frozen -- i.e. before any context starts.
|
||||
void calogForEach(CalogT *calog, void (*visit)(const CalogEntryT *entry, void *ud), void *ud);
|
||||
|
||||
// ---- abort bookkeeping (calogAbortAll is the public half; see calog.h) ----
|
||||
// Mark the calling context: the abort error is being raised into its VM right now. Call it wherever
|
||||
// the abort is injected into running script code.
|
||||
void calogAbortRaise(void);
|
||||
// Did WE raise the abort into the calling context's VM? Engine adapters ask this -- not the broader
|
||||
// calogAborting -- before swallowing an eval failure, so a script that genuinely failed (a syntax
|
||||
// error, say) still gets its diagnostic while some other script is tearing the runtime down.
|
||||
bool calogAbortRaised(CalogT *calog);
|
||||
|
||||
// ---- 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);
|
||||
|
|
@ -169,6 +178,9 @@ void calogFnFinalizeForeign(CalogFnT *fn);
|
|||
void calogFnReclaim(CalogFnT *fn);
|
||||
CalogNativeFnT calogFnNative(const CalogFnT *fn);
|
||||
void calogFnMarkDead(CalogFnT *fn);
|
||||
// Take a reference only if the callable has not already dropped to zero (a finalize in flight).
|
||||
// Call with the owner context's queueMutex held; see the definition for why that is required.
|
||||
bool calogFnRetainIfLive(CalogFnT *fn);
|
||||
uint64_t calogFnOwner(const CalogFnT *fn);
|
||||
CalogT *calogFnRuntime(const CalogFnT *fn);
|
||||
void *calogFnUserData(const CalogFnT *fn);
|
||||
|
|
@ -197,15 +209,27 @@ void calogContextUntrackFn(CalogT *runtime, uint64_t ownerCtxId, CalogFn
|
|||
// watchdog starts, so a cross-thread read of it (Janet) is safe without atomics. All nine enforcing
|
||||
// engines share this state; s7 is allow-list only (its loops bypass its begin-hook).
|
||||
typedef struct CalogLimitStateT {
|
||||
int64_t memUsed; // bytes currently charged against the cap
|
||||
int64_t memCap; // 0 = unlimited
|
||||
uint64_t deadlineMs; // 0 = no wall-clock deadline; else a calogMonotonicMillis() value
|
||||
// SHARED between a sandboxed context and every task it spawns, so the charge comes from several
|
||||
// threads: atomic, and every adapter's `+=` / `-=` / compare is therefore an atomic op with no
|
||||
// adapter-side change. A check-then-charge pair is still two operations, so concurrent
|
||||
// allocations can overshoot the cap by at most one allocation per thread -- the same
|
||||
// allocation-granular overshoot the single-context case already documents.
|
||||
_Atomic int64_t memUsed; // bytes currently charged against the cap
|
||||
int64_t memCap; // 0 = unlimited
|
||||
uint64_t deadlineMs; // 0 = no wall-clock deadline; else a calogMonotonicMillis() value
|
||||
} CalogLimitStateT;
|
||||
|
||||
// The limit state an engine adapter installs its allocator/hook against, or NULL if the context is
|
||||
// unlimited (so the common case installs nothing).
|
||||
CalogLimitStateT *calogContextLimitState(CalogContextT *context);
|
||||
|
||||
// calogContextOpen / calogContextLoad for a script spawning a task: the new context INHERITS the
|
||||
// calling script's sandbox (shared memory pool, shared absolute deadline, same allow-list) instead
|
||||
// of starting unrestricted, and is refused -- NULL -- once the tree has reached maxContexts. Used by
|
||||
// taskSpawn/taskLoad. An unrestricted caller gets an unrestricted child, exactly as before.
|
||||
CalogContextT *calogContextOpenInheriting(CalogT *calog, const CalogEngineT *engine);
|
||||
CalogContextT *calogContextLoadInheriting(CalogT *calog, const char *baseFileName);
|
||||
|
||||
// Monotonic milliseconds -- for computing a deadline at open and checking it in the engine hooks.
|
||||
uint64_t calogMonotonicMillis(void);
|
||||
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@
|
|||
// The base for a signal-derived exit code (128 + signal number), the shell convention.
|
||||
#define SIGNAL_EXIT_BASE 128
|
||||
|
||||
static void abortIfSignalled(CalogT *calog);
|
||||
#ifdef _WIN32
|
||||
static BOOL WINAPI consoleHandler(DWORD ctrlType);
|
||||
#endif
|
||||
|
|
@ -99,6 +100,13 @@ static bool resolveArg(const char *arg, const CalogEngineT **outE
|
|||
static _Atomic bool gShutdown = false;
|
||||
static _Atomic bool gExitRequested = false;
|
||||
static _Atomic int32_t gExitCode = 0;
|
||||
// Set by the signal handler, consumed by main: a signal means STOP, so the runtime is latched
|
||||
// aborting exactly as calogExit latches it. The handler cannot do that itself -- calogAbortAll is
|
||||
// not async-signal-safe and the handler has no runtime pointer -- so it sets this and main acts on
|
||||
// it the moment the pump loop drops out. Without it, tearing down had to wait for every script to
|
||||
// finish its current chunk, so Ctrl-C on a long-running script did nothing until the script was
|
||||
// good and ready.
|
||||
static _Atomic bool gAbortRequested = false;
|
||||
|
||||
// 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
|
||||
|
|
@ -170,6 +178,23 @@ const char *__lsan_default_options(void) {
|
|||
}
|
||||
|
||||
|
||||
// Act on a signal that arrived while scripts were running: latch the runtime aborting, exactly as a
|
||||
// script's own calogExit latches it, so every context stops at its next native call or interpreter
|
||||
// hook rather than running to the end of its chunk. The signal handler cannot do this itself --
|
||||
// calogAbortAll is not async-signal-safe -- so it records the request and this consumes it. Being
|
||||
// idempotent, calling it on a run that was never signalled costs one atomic load.
|
||||
static void abortIfSignalled(CalogT *calog) {
|
||||
CalogValueT ignored;
|
||||
|
||||
if (!atomic_load(&gAbortRequested)) {
|
||||
return;
|
||||
}
|
||||
calogValueNil(&ignored);
|
||||
calogAbortAll(calog, &ignored);
|
||||
calogValueFree(&ignored);
|
||||
}
|
||||
|
||||
|
||||
// Find the engine that claims file extension ext (case-insensitive), or NULL if none does.
|
||||
static const CalogEngineT *engineForExtension(const char *ext) {
|
||||
size_t index;
|
||||
|
|
@ -285,11 +310,12 @@ 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 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.
|
||||
// Signal handler for SIGINT/SIGTERM: request the same shutdown, and the same abort, as calogExit.
|
||||
// Only async-signal-safe atomic stores happen here; main does the latching (see gAbortRequested).
|
||||
// Aborting is the point -- an interrupt that a script can outrun is not an interrupt -- so Ctrl-C
|
||||
// stops a running script at its next native call or interpreter hook rather than waiting for it.
|
||||
static void onSignal(int sig) {
|
||||
atomic_store(&gAbortRequested, true);
|
||||
requestShutdown((int32_t)(SIGNAL_EXIT_BASE + sig));
|
||||
}
|
||||
|
||||
|
|
@ -300,6 +326,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(&gAbortRequested, true);
|
||||
requestShutdown((int32_t)(SIGNAL_EXIT_BASE + SIGTERM));
|
||||
return TRUE;
|
||||
}
|
||||
|
|
@ -623,8 +650,13 @@ int main(int argc, char **argv) {
|
|||
atomic_store(&gShutdown, true);
|
||||
break;
|
||||
}
|
||||
abortIfSignalled(calog);
|
||||
nanosleep(&tick, NULL);
|
||||
}
|
||||
// The loop drops out the moment a signal lands (the handler sets gShutdown), so the latch is
|
||||
// applied here as well -- otherwise a run signalled on its very first tick would tear down with
|
||||
// its scripts still running, and calogDestroy would sit waiting to join them.
|
||||
abortIfSignalled(calog);
|
||||
calogPump(calog);
|
||||
calogTaskReap();
|
||||
|
||||
|
|
|
|||
330
src/context.c
330
src/context.c
|
|
@ -85,6 +85,22 @@ typedef struct MessageT {
|
|||
struct MessageT *next;
|
||||
} MessageT;
|
||||
|
||||
// One sandbox policy, shared by a context and every task it spawns. Sharing is the whole design: a
|
||||
// spawned child does not get a fresh budget, it draws on the SAME memory pool, dies at the SAME
|
||||
// absolute deadline, and is bound by the SAME allow-list, so a limited script cannot escape its
|
||||
// policy by doing its work in a child. `live` is both the reference count and the number of contexts
|
||||
// in the tree, which is what maxContexts bounds -- without it, inheriting the other three limits
|
||||
// would still leave `while true do taskSpawn(...) end` to exhaust the host's threads.
|
||||
typedef struct SandboxT {
|
||||
CalogLimitStateT state; // what the engine adapters install their allocator/hook against
|
||||
char **allow; // sorted allowed-native names, or NULL = every native permitted
|
||||
int32_t allowCount;
|
||||
int32_t maxContexts; // 0 = unbounded
|
||||
bool metered; // a memory cap or a deadline is set, so adapters need `state`
|
||||
_Atomic int32_t live; // contexts sharing this policy; the last one out frees it
|
||||
} SandboxT;
|
||||
|
||||
|
||||
struct CalogContextT {
|
||||
uint64_t id;
|
||||
CalogT *broker;
|
||||
|
|
@ -102,11 +118,9 @@ struct CalogContextT {
|
|||
_Atomic bool finished; // set by threadMain just before the thread returns; lets any thread safely reap it
|
||||
bool started;
|
||||
bool interpDead; // set (under ctxMutex) once the interpreter is torn down
|
||||
CalogLimitStateT limits; // memCap/deadlineMs/memUsed, enforced by the engine adapter
|
||||
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: this script stops, the runtime lives on
|
||||
SandboxT *sandbox; // the policy this context runs under, SHARED with any task it
|
||||
// spawns, or NULL when the context is unrestricted
|
||||
_Atomic bool abortRaised; // the abort error was raised INTO this VM (see calogAbortRaised)
|
||||
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
|
||||
|
|
@ -138,6 +152,7 @@ typedef struct TraceFrameT {
|
|||
static _Thread_local TraceFrameT traceFrames[CALOG_TRACE_MAX];
|
||||
static _Thread_local int32_t traceDepth = 0;
|
||||
|
||||
|
||||
static int32_t actorInvokeCallable(CalogFnT *callable, CalogValueT *args, int32_t argCount, CalogValueT *result);
|
||||
static void actorReleaseCallable(CalogFnT *callable);
|
||||
static int32_t actorRoute(CalogT *calog, CalogEntryT *entry, CalogValueT *args, int32_t argCount, CalogValueT *result);
|
||||
|
|
@ -147,7 +162,10 @@ static void contextDispatchError(CalogT *calog, MessageT *message);
|
|||
static void contextDispatchEval(CalogContextT *context, MessageT *message);
|
||||
static void contextDispatchRelease(MessageT *message);
|
||||
static void contextDrainQueue(CalogContextT *context);
|
||||
static void contextFreeAllow(CalogContextT *context);
|
||||
static CalogContextT *contextOpenWithSandbox(CalogT *broker, const CalogEngineT *engine, SandboxT *sandbox);
|
||||
static SandboxT *sandboxCreate(const CalogLimitsT *limits);
|
||||
static void sandboxRelease(SandboxT *sandbox);
|
||||
static SandboxT *sandboxRetainForChild(SandboxT *sandbox);
|
||||
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);
|
||||
|
|
@ -353,8 +371,8 @@ static int32_t actorRoute(CalogT *calog, CalogEntryT *entry, CalogValueT *args,
|
|||
// Sandboxing: a limited context (allow != NULL -- only a limited SCRIPT context ever sets it;
|
||||
// the host context never does) may call only the natives on its allow-list. currentContext is
|
||||
// the CALLING context, so this gates every engine uniformly through the one dispatch choke point.
|
||||
if (currentContext != NULL && currentContext->allow != NULL &&
|
||||
bsearch(&entry->name, currentContext->allow, (size_t)currentContext->allowCount, sizeof(char *), limitNameCompare) == NULL) {
|
||||
if (currentContext != NULL && currentContext->sandbox != NULL && currentContext->sandbox->allow != NULL &&
|
||||
bsearch(&entry->name, currentContext->sandbox->allow, (size_t)currentContext->sandbox->allowCount, sizeof(char *), limitNameCompare) == NULL) {
|
||||
calogValueNil(result);
|
||||
return calogFail(result, calogErrUnsupportedE, "native not permitted by this context's allow-list");
|
||||
}
|
||||
|
|
@ -441,7 +459,7 @@ void calogActorShutdown(CalogT *calog) {
|
|||
continue;
|
||||
}
|
||||
contextDrainQueue(context);
|
||||
contextFreeAllow(context);
|
||||
sandboxRelease(context->sandbox);
|
||||
pthread_mutex_destroy(&context->queueMutex);
|
||||
pthread_cond_destroy(&context->queueCond);
|
||||
free(context);
|
||||
|
|
@ -476,9 +494,11 @@ 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);
|
||||
// ctxMutex is deliberately NOT destroyed here. Finalizing a callable locks it -- to untrack the
|
||||
// callable and to resolve its owner -- and callables are still being finalized after this
|
||||
// returns: the after-context destroy hooks (pubsub, export, kv, task) release everything their
|
||||
// registries hold. calogDestroy destroys it once those have run. Every slot is gone by now, so
|
||||
// those late locks find nothing, which is the answer they want.
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -583,6 +603,12 @@ void calogDestroy(CalogT *calog) {
|
|||
}
|
||||
free(calog->destroyHooks);
|
||||
free(calog->contextHooks);
|
||||
// Only now is ctxMutex dead. The after-context hooks above release the callables their registries
|
||||
// hold, and finalizing one locks this mutex (calogContextUntrackFn, and calogContextRegistered to
|
||||
// decide whether the engine release may run) -- so destroying it inside calogActorShutdown, as
|
||||
// this used to, left those hooks locking a destroyed mutex. ThreadSanitizer calls that "use of an
|
||||
// invalid mutex"; the C standard calls it undefined behaviour.
|
||||
pthread_mutex_destroy(&calog->ctxMutex);
|
||||
calogBrokerDestroy(calog);
|
||||
}
|
||||
|
||||
|
|
@ -598,7 +624,10 @@ uint64_t calogMonotonicMillis(void) {
|
|||
|
||||
|
||||
CalogLimitStateT *calogContextLimitState(CalogContextT *context) {
|
||||
return context->limited ? &context->limits : NULL;
|
||||
if (context->sandbox == NULL || !context->sandbox->metered) {
|
||||
return NULL;
|
||||
}
|
||||
return &context->sandbox->state;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -607,18 +636,99 @@ static int limitNameCompare(const void *a, const void *b) {
|
|||
}
|
||||
|
||||
|
||||
static void contextFreeAllow(CalogContextT *context) {
|
||||
// Build the policy for a freshly limited context. Returns NULL for "no limits at all" (which is not
|
||||
// a failure) and leaves *failed true only if an allocation failed. The deadline is resolved to an
|
||||
// ABSOLUTE instant here, once: every context in the tree then shares that instant, so a chain of
|
||||
// spawns cannot walk the budget forward by restarting it.
|
||||
static SandboxT *sandboxCreate(const CalogLimitsT *limits) {
|
||||
SandboxT *sandbox;
|
||||
|
||||
if (limits == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
if (limits->memoryBytes <= 0 && limits->wallClockMillis <= 0 && limits->allowList == NULL && limits->maxContexts <= 0) {
|
||||
return NULL;
|
||||
}
|
||||
sandbox = (SandboxT *)calloc(1, sizeof(*sandbox));
|
||||
if (sandbox == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
atomic_store(&sandbox->live, 1);
|
||||
sandbox->maxContexts = limits->maxContexts > 0 ? limits->maxContexts : 0;
|
||||
if (limits->memoryBytes > 0) {
|
||||
sandbox->state.memCap = limits->memoryBytes;
|
||||
sandbox->metered = true;
|
||||
}
|
||||
if (limits->wallClockMillis > 0) {
|
||||
sandbox->state.deadlineMs = calogMonotonicMillis() + (uint64_t)limits->wallClockMillis;
|
||||
sandbox->metered = true;
|
||||
}
|
||||
if (limits->allowList != NULL) {
|
||||
int32_t count;
|
||||
int32_t i;
|
||||
|
||||
count = 0;
|
||||
while (limits->allowList[count] != NULL) {
|
||||
count++;
|
||||
}
|
||||
sandbox->allow = (char **)calloc((size_t)(count > 0 ? count : 1), sizeof(char *));
|
||||
if (sandbox->allow == NULL) {
|
||||
free(sandbox);
|
||||
return NULL;
|
||||
}
|
||||
for (i = 0; i < count; i++) {
|
||||
sandbox->allow[i] = strdup(limits->allowList[i]);
|
||||
if (sandbox->allow[i] == NULL) {
|
||||
sandbox->allowCount = i;
|
||||
sandboxRelease(sandbox);
|
||||
return NULL;
|
||||
}
|
||||
}
|
||||
sandbox->allowCount = count;
|
||||
qsort(sandbox->allow, (size_t)count, sizeof(char *), limitNameCompare);
|
||||
}
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
|
||||
// Drop one context's reference. The policy outlives any single context in its tree -- a parent that
|
||||
// finishes first must not pull the budget out from under the children it spawned -- so only the last
|
||||
// one out frees it. NULL (an unrestricted context) is a no-op.
|
||||
static void sandboxRelease(SandboxT *sandbox) {
|
||||
int32_t i;
|
||||
|
||||
if (context->allow == NULL) {
|
||||
if (sandbox == NULL) {
|
||||
return;
|
||||
}
|
||||
for (i = 0; i < context->allowCount; i++) {
|
||||
free(context->allow[i]);
|
||||
if (atomic_fetch_sub(&sandbox->live, 1) != 1) {
|
||||
return;
|
||||
}
|
||||
for (i = 0; i < sandbox->allowCount; i++) {
|
||||
free(sandbox->allow[i]);
|
||||
}
|
||||
free(sandbox->allow);
|
||||
free(sandbox);
|
||||
}
|
||||
|
||||
|
||||
// Take a reference for a context about to be spawned INTO this policy, refusing if that would push
|
||||
// the tree past maxContexts. The CAS loop is what makes the bound real: two scripts spawning at once
|
||||
// must not both read live == max-1 and both succeed.
|
||||
static SandboxT *sandboxRetainForChild(SandboxT *sandbox) {
|
||||
int32_t current;
|
||||
|
||||
if (sandbox == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
current = atomic_load(&sandbox->live);
|
||||
for (;;) {
|
||||
if (sandbox->maxContexts > 0 && current >= sandbox->maxContexts) {
|
||||
return NULL;
|
||||
}
|
||||
if (atomic_compare_exchange_weak(&sandbox->live, ¤t, current + 1)) {
|
||||
return sandbox;
|
||||
}
|
||||
}
|
||||
free(context->allow);
|
||||
context->allow = NULL;
|
||||
context->allowCount = 0;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -627,54 +737,27 @@ CalogContextT *calogContextOpen(CalogT *broker, const CalogEngineT *engine) {
|
|||
}
|
||||
|
||||
|
||||
CalogContextT *calogContextOpenLimited(CalogT *broker, const CalogEngineT *engine, const CalogLimitsT *limits) {
|
||||
// Open a context already bound to `sandbox`, taking ownership of the reference the caller holds (so
|
||||
// a failure here releases it). Both public entry points funnel through this: the difference between
|
||||
// them is only WHERE the policy came from -- freshly built by the host, or inherited from the script
|
||||
// that asked for the spawn.
|
||||
static CalogContextT *contextOpenWithSandbox(CalogT *broker, const CalogEngineT *engine, SandboxT *sandbox) {
|
||||
CalogContextT *context;
|
||||
int64_t index;
|
||||
uint32_t generation;
|
||||
|
||||
context = (CalogContextT *)calloc(1, sizeof(*context));
|
||||
if (context == NULL) {
|
||||
sandboxRelease(sandbox);
|
||||
return NULL;
|
||||
}
|
||||
context->broker = broker;
|
||||
context->engine = engine;
|
||||
// Bound BEFORE the thread starts: createInterpreter reads the limit state to install its
|
||||
// allocator/hook and runs on the new thread, which pthread_create orders after this write.
|
||||
context->sandbox = sandbox;
|
||||
pthread_mutex_init(&context->queueMutex, NULL);
|
||||
pthread_cond_init(&context->queueCond, NULL);
|
||||
// Sandboxing: copy the policy BEFORE the thread starts. createInterpreter, which reads the limit
|
||||
// state to install its allocator/hook, runs on the new thread, and pthread_create orders it
|
||||
// after these writes. A NULL policy leaves the context unlimited.
|
||||
if (limits != NULL) {
|
||||
if (limits->memoryBytes > 0) {
|
||||
context->limits.memCap = limits->memoryBytes;
|
||||
context->limited = true;
|
||||
}
|
||||
if (limits->wallClockMillis > 0) {
|
||||
context->limits.deadlineMs = calogMonotonicMillis() + (uint64_t)limits->wallClockMillis;
|
||||
context->limited = true;
|
||||
}
|
||||
if (limits->allowList != NULL) {
|
||||
int32_t count;
|
||||
int32_t i;
|
||||
count = 0;
|
||||
while (limits->allowList[count] != NULL) {
|
||||
count++;
|
||||
}
|
||||
context->allow = (char **)calloc((size_t)(count > 0 ? count : 1), sizeof(char *));
|
||||
if (context->allow == NULL) {
|
||||
goto fail;
|
||||
}
|
||||
for (i = 0; i < count; i++) {
|
||||
context->allow[i] = strdup(limits->allowList[i]);
|
||||
if (context->allow[i] == NULL) {
|
||||
context->allowCount = i;
|
||||
goto fail;
|
||||
}
|
||||
}
|
||||
context->allowCount = count;
|
||||
qsort(context->allow, (size_t)count, sizeof(char *), limitNameCompare);
|
||||
context->limited = true;
|
||||
}
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&broker->ctxMutex);
|
||||
// The runtime is being destroyed: refuse rather than join a registry that is already being
|
||||
|
|
@ -728,9 +811,10 @@ CalogContextT *calogContextOpenLimited(CalogT *broker, const CalogEngineT *engin
|
|||
return context;
|
||||
|
||||
fail:
|
||||
// Shared teardown for every failure above: nothing beyond the queue's own mutex/cond, the
|
||||
// copied allow-list, and the context shell has been allocated at any of these points.
|
||||
contextFreeAllow(context);
|
||||
// Shared teardown for every failure above: nothing beyond the queue's own mutex/cond, this
|
||||
// context's reference on the policy, and the context shell has been allocated at any of these
|
||||
// points.
|
||||
sandboxRelease(context->sandbox);
|
||||
pthread_mutex_destroy(&context->queueMutex);
|
||||
pthread_cond_destroy(&context->queueCond);
|
||||
free(context);
|
||||
|
|
@ -738,11 +822,47 @@ fail:
|
|||
}
|
||||
|
||||
|
||||
CalogContextT *calogContextOpenLimited(CalogT *broker, const CalogEngineT *engine, const CalogLimitsT *limits) {
|
||||
SandboxT *sandbox;
|
||||
|
||||
// A policy that asks for nothing is no policy: sandboxCreate returns NULL for it, which is
|
||||
// exactly what an unrestricted context wants. Telling that apart from an allocation failure is
|
||||
// why the emptiness test lives there rather than here.
|
||||
sandbox = sandboxCreate(limits);
|
||||
if (sandbox == NULL && limits != NULL && (limits->memoryBytes > 0 || limits->wallClockMillis > 0 ||
|
||||
limits->allowList != NULL || limits->maxContexts > 0)) {
|
||||
return NULL;
|
||||
}
|
||||
return contextOpenWithSandbox(broker, engine, sandbox);
|
||||
}
|
||||
|
||||
|
||||
// Open a context under the CALLING script's policy -- what taskSpawn/taskLoad use. An unrestricted
|
||||
// caller yields an unrestricted child (the ordinary case, and free). A sandboxed caller yields a
|
||||
// child sharing its memory pool, its deadline and its allow-list, and the spawn is REFUSED once the
|
||||
// tree is at maxContexts. Returns NULL on refusal, which the task natives report as an error rather
|
||||
// than silently minting the unlimited context this used to hand out.
|
||||
CalogContextT *calogContextOpenInheriting(CalogT *broker, const CalogEngineT *engine) {
|
||||
SandboxT *sandbox;
|
||||
|
||||
if (currentContext == NULL || currentContext->sandbox == NULL) {
|
||||
return contextOpenWithSandbox(broker, engine, NULL);
|
||||
}
|
||||
sandbox = sandboxRetainForChild(currentContext->sandbox);
|
||||
if (sandbox == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
return contextOpenWithSandbox(broker, engine, sandbox);
|
||||
}
|
||||
|
||||
|
||||
// Search the registered engines for a file "<baseFileName>.<ext>". The first engine
|
||||
// (in registration order), then its first extension, that names a readable file wins:
|
||||
// its contents are loaded fire-and-forget into a fresh context on that engine. Reads
|
||||
// the file on the calling thread. Returns NULL if nothing matched or the load failed.
|
||||
CalogContextT *calogContextLoad(CalogT *calog, const char *baseFileName) {
|
||||
// Body of both load entry points. `inherit` picks which open to use, so the file search itself has
|
||||
// one implementation rather than two that can drift.
|
||||
static CalogContextT *contextLoadCommon(CalogT *calog, const char *baseFileName, bool inherit) {
|
||||
int64_t engineIndex;
|
||||
|
||||
for (engineIndex = 0; engineIndex < calog->engineCount; engineIndex++) {
|
||||
|
|
@ -789,7 +909,7 @@ CalogContextT *calogContextLoad(CalogT *calog, const char *baseFileName) {
|
|||
readCount = fread(source, 1, (size_t)fileSize, file);
|
||||
fclose(file);
|
||||
source[readCount] = '\0';
|
||||
context = calogContextOpen(calog, engine);
|
||||
context = inherit ? calogContextOpenInheriting(calog, engine) : calogContextOpen(calog, engine);
|
||||
if (context == NULL) {
|
||||
free(source);
|
||||
return NULL;
|
||||
|
|
@ -807,6 +927,18 @@ CalogContextT *calogContextLoad(CalogT *calog, const char *baseFileName) {
|
|||
}
|
||||
|
||||
|
||||
CalogContextT *calogContextLoad(CalogT *calog, const char *baseFileName) {
|
||||
return contextLoadCommon(calog, baseFileName, false);
|
||||
}
|
||||
|
||||
|
||||
// calogContextLoad for a script's own taskLoad: the loaded context inherits the calling script's
|
||||
// sandbox rather than starting unrestricted (see calogContextOpenInheriting).
|
||||
CalogContextT *calogContextLoadInheriting(CalogT *calog, const char *baseFileName) {
|
||||
return contextLoadCommon(calog, baseFileName, true);
|
||||
}
|
||||
|
||||
|
||||
CalogT *calogCurrent(void) {
|
||||
return currentContext != NULL ? currentContext->broker : NULL;
|
||||
}
|
||||
|
|
@ -854,37 +986,44 @@ bool calogCurrentShuttingDown(void) {
|
|||
// scripts are stopped either way -- that is what the latch is for.
|
||||
int32_t calogAbortAll(CalogT *calog, CalogValueT *result) {
|
||||
atomic_store(&calog->aborting, true);
|
||||
calogAbortRaise();
|
||||
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) {
|
||||
// Record that the abort error is being raised INTO the calling context's VM. Called from the three
|
||||
// -- and only three -- places the abort can enter running script code: the native that latches the
|
||||
// runtime (calogAbortAll), the dispatch choke point that refuses every later native (calogCall), and
|
||||
// an engine's interrupt hook unwinding a script that calls no natives at all. Marking at the moment
|
||||
// of injection is what lets calogAbortRaised stay exact. No-op off a context thread.
|
||||
void calogAbortRaise(void) {
|
||||
if (currentContext == NULL || currentContext->id == CALOG_HOST_ID) {
|
||||
return calogFail(result, calogErrUnsupportedE, "calogAbortCurrent: no calling script to end");
|
||||
return;
|
||||
}
|
||||
atomic_store(¤tContext->aborting, true);
|
||||
calogCurrentRetire();
|
||||
return calogFail(result, calogErrAbortE, CALOG_ABORT_MESSAGE);
|
||||
atomic_store(¤tContext->abortRaised, true);
|
||||
}
|
||||
|
||||
|
||||
// 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;
|
||||
}
|
||||
// True when the error an engine is holding is one WE put there to unwind this script, rather than
|
||||
// one the script earned. Engine adapters ask this instead of calogAborting when deciding whether an
|
||||
// eval failure deserves a diagnostic: "the runtime is aborting" is not the same question, because a
|
||||
// script can be handed to an engine with a syntax error in it while some other script is tearing the
|
||||
// runtime down, and that script really did fail -- silently swallowing it hides the one piece of
|
||||
// information its author needs. Exact for every engine, including the four whose eval API compiles
|
||||
// and runs in one call and so cannot tell the two apart by return code alone.
|
||||
bool calogAbortRaised(CalogT *calog) {
|
||||
if (currentContext == NULL || currentContext->broker != calog) {
|
||||
return false;
|
||||
}
|
||||
return atomic_load(¤tContext->aborting);
|
||||
return atomic_load(¤tContext->abortRaised);
|
||||
}
|
||||
|
||||
|
||||
// True when the caller must stop running script code: this runtime was latched by calogAbortAll.
|
||||
// One query, so the dispatch choke point, every engine adapter and every interrupt hook ask the same
|
||||
// question. To ask instead whether a FAILURE was caused by that abort, use calogAbortRaised.
|
||||
bool calogAborting(CalogT *calog) {
|
||||
return atomic_load(&calog->aborting);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -923,7 +1062,7 @@ void calogContextClose(CalogContextT *context) {
|
|||
}
|
||||
pthread_mutex_unlock(&broker->ctxMutex);
|
||||
contextDrainQueue(context);
|
||||
contextFreeAllow(context);
|
||||
sandboxRelease(context->sandbox);
|
||||
pthread_mutex_destroy(&context->queueMutex);
|
||||
pthread_cond_destroy(&context->queueCond);
|
||||
free(context);
|
||||
|
|
@ -1297,21 +1436,32 @@ static void contextReclaimCallables(CalogContextT *context) {
|
|||
|
||||
// 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.
|
||||
// walked. Holding a reference across the sweep is what keeps such a cascade from freeing an
|
||||
// entry we have not reached yet.
|
||||
//
|
||||
// Both the steal and the retains happen in ONE critical section, and the retain is conditional.
|
||||
// A foreign thread can drop the last reference to any of these at any moment; retaining after
|
||||
// that drop would resurrect a callable whose finalize is already committed, and the sweep's own
|
||||
// release would then free it a second time. calogFnRetainIfLive refuses that case, so an entry
|
||||
// already being finalized is simply left out -- its finalize frees it, as it was going to. And
|
||||
// no shell can be freed while this lock is held, because every free path untracks first and
|
||||
// untrack needs this same mutex, so inspecting the refcount here is safe.
|
||||
pthread_mutex_lock(&context->queueMutex);
|
||||
owned = context->ownedFns;
|
||||
count = context->ownedCount;
|
||||
context->ownedFns = NULL;
|
||||
owned = context->ownedFns;
|
||||
count = 0;
|
||||
for (index = 0; index < context->ownedCount; index++) {
|
||||
if (calogFnRetainIfLive(owned[index])) {
|
||||
owned[count] = owned[index]; // compact: keep only what this sweep now owns
|
||||
count++;
|
||||
}
|
||||
}
|
||||
context->ownedFns = NULL;
|
||||
context->ownedCount = 0;
|
||||
context->ownedCap = 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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ static int janetNativeGc(void *data, size_t len);
|
|||
static int32_t janetScriptInvoke(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
|
||||
static void janetScriptRelease(CalogFnT *callable);
|
||||
static int32_t janetToValue(CalogJanetT *context, Janet value, CalogValueT *out, int32_t depth);
|
||||
static void janetSealHostAccess(JanetTable *env);
|
||||
static void *janetWatchdog(void *arg);
|
||||
static int32_t janetWrapFunction(CalogJanetT *context, JanetFunction *function, CalogFnT **out);
|
||||
|
||||
|
|
@ -201,18 +202,62 @@ void calogJanetFree(void *ptr) {
|
|||
}
|
||||
|
||||
|
||||
// The deadline watchdog: Janet has no in-loop time hook, so a separate thread interrupts the VM once
|
||||
// the wall-clock deadline passes (janet_interpreter_interrupt is cross-thread-safe). It polls at a
|
||||
// coarse interval and also exits promptly when asked (at context teardown).
|
||||
// Shut every door out of the VM that does not go through a calog native.
|
||||
//
|
||||
// calog's contract is that a script reaches the host only through registered natives, where the
|
||||
// allow-list, the memory cap and the wall-clock budget can see it. Janet ships its own filesystem,
|
||||
// subprocess, socket, environment and FFI bindings, none of which pass through calogCall, so no
|
||||
// policy calog can express applies to them -- and ffi/ is worse than the rest together, since jitfn
|
||||
// executes attacker-supplied machine code inside this process.
|
||||
//
|
||||
// janet_sandbox is Janet's own mechanism and covers most of it: a guarded cfun asserts against the
|
||||
// mask and raises an ordinary Janet error, which the adapter reports like any other script failure.
|
||||
// Two bindings are NOT guarded and have to go by hand -- os/exit calls exit() directly (taking the
|
||||
// host down mid-run, around calogExit's ordered teardown) and os/cwd leaks the working directory.
|
||||
// Defining a name to nil is how janet_def removes an existing binding from the env table.
|
||||
//
|
||||
// Everything dropped has a gated equivalent: fs* for files, procRun for processes, net*/http* for
|
||||
// sockets, calogExit for ending the run. Compilation is deliberately left alone -- Janet scripts
|
||||
// need it and it reaches nothing outside the VM.
|
||||
static void janetSealHostAccess(JanetTable *env) {
|
||||
static const char *const removed[] = { "os/cwd", "os/exit" };
|
||||
size_t index;
|
||||
|
||||
janet_sandbox(JANET_SANDBOX_FS | JANET_SANDBOX_ENV | JANET_SANDBOX_FFI |
|
||||
JANET_SANDBOX_SUBPROCESS | JANET_SANDBOX_NET | JANET_SANDBOX_DYNAMIC_MODULES |
|
||||
JANET_SANDBOX_SIGNAL);
|
||||
for (index = 0; index < sizeof(removed) / sizeof(removed[0]); index++) {
|
||||
janet_def(env, removed[index], janet_wrap_nil(), NULL);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// The watchdog: Janet has no in-loop hook, so a separate thread interrupts the VM
|
||||
// (janet_interpreter_interrupt is cross-thread-safe) for either reason a script must stop -- the
|
||||
// runtime was latched aborting, or a time-limited context ran past its deadline. It polls at a
|
||||
// coarse interval and exits promptly when asked (at context teardown).
|
||||
//
|
||||
// Every context gets one, not just time-limited ones: the abort watch is what lets Ctrl-C reach a
|
||||
// script that calls no natives at all, which nothing else can interrupt on this engine. It does NOT
|
||||
// call calogAbortRaise -- that marks the CALLING context and this is a foreign thread -- which is
|
||||
// why calogJanetRun asks calogAborting rather than calogAbortRaised. Janet can afford that looser
|
||||
// question because its own JANET_DO_ERROR_RUNTIME flag already separates a parse failure from a
|
||||
// script that actually ran, so a syntax error still reports mid-teardown.
|
||||
static void *janetWatchdog(void *arg) {
|
||||
CalogJanetT *context;
|
||||
struct timespec tick;
|
||||
CalogJanetT *context;
|
||||
CalogLimitStateT *limits;
|
||||
struct timespec tick;
|
||||
|
||||
context = (CalogJanetT *)arg;
|
||||
limits = context->limits;
|
||||
tick.tv_sec = 0;
|
||||
tick.tv_nsec = 5 * 1000 * 1000; // 5 ms
|
||||
while (!atomic_load(&context->wdStop)) {
|
||||
if (calogMonotonicMillis() >= context->limits->deadlineMs) {
|
||||
if (calogAborting(context->broker)) {
|
||||
janet_interpreter_interrupt(context->janetVm);
|
||||
break;
|
||||
}
|
||||
if (limits != NULL && limits->deadlineMs != 0 && calogMonotonicMillis() >= limits->deadlineMs) {
|
||||
janet_interpreter_interrupt(context->janetVm);
|
||||
break;
|
||||
}
|
||||
|
|
@ -251,6 +296,7 @@ int32_t calogJanetCreate(CalogJanetT **out, CalogT *broker, uint64_t ctxId, Calo
|
|||
// The core env is memoized and gcrooted internally by Janet, so the defs installed by
|
||||
// calogJanetExpose survive collection for the VM's lifetime.
|
||||
context->env = janet_core_env(NULL);
|
||||
janetSealHostAccess(context->env);
|
||||
context->broker = broker;
|
||||
context->ctxId = ctxId;
|
||||
context->limits = limits;
|
||||
|
|
@ -262,11 +308,11 @@ int32_t calogJanetCreate(CalogJanetT **out, CalogT *broker, uint64_t ctxId, Calo
|
|||
if (limits != NULL && limits->memCap > 0) {
|
||||
gJanetLimits = limits;
|
||||
}
|
||||
if (limits != NULL && limits->deadlineMs > 0) {
|
||||
atomic_store(&context->wdStop, false);
|
||||
if (pthread_create(&context->watchdog, NULL, janetWatchdog, context) == 0) {
|
||||
context->hasWatchdog = true;
|
||||
}
|
||||
// Started for every context: the watchdog carries the abort watch, which an unlimited context
|
||||
// needs just as much as a limited one (see janetWatchdog).
|
||||
atomic_store(&context->wdStop, false);
|
||||
if (pthread_create(&context->watchdog, NULL, janetWatchdog, context) == 0) {
|
||||
context->hasWatchdog = true;
|
||||
}
|
||||
*out = context;
|
||||
return calogOkE;
|
||||
|
|
|
|||
|
|
@ -89,11 +89,10 @@ int32_t calogJsCreate(CalogJsT **out, CalogT *broker, uint64_t ctxId, CalogLimit
|
|||
context->broker = broker;
|
||||
context->ctxId = ctxId;
|
||||
context->limits = limits;
|
||||
// A time-limited context checks the wall-clock deadline from the interrupt handler (fired by the
|
||||
// interpreter periodically), recovering the context through the runtime opaque set just below.
|
||||
if (limits != NULL && limits->deadlineMs > 0) {
|
||||
JS_SetInterruptHandler(context->rt, jsInterrupt, context);
|
||||
}
|
||||
// Every context gets the interrupt handler: it carries the abort check, which an unlimited
|
||||
// context needs just as much as a limited one (see jsInterrupt). The context is recovered
|
||||
// through the opaque passed here.
|
||||
JS_SetInterruptHandler(context->rt, jsInterrupt, context);
|
||||
JS_SetContextOpaque(context->ctx, context);
|
||||
// A callable, finalized class wraps a foreign CalogFnT pushed into JS. The runtime
|
||||
// opaque lets the finalizer (which gets only the runtime) recover the context.
|
||||
|
|
@ -449,9 +448,12 @@ int32_t calogJsRun(CalogJsT *context, const char *source) {
|
|||
JSValue exc;
|
||||
const char *message;
|
||||
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)) {
|
||||
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, so report
|
||||
// nothing. Asked as "is this exception ours" rather than "is the runtime aborting":
|
||||
// JS_Eval compiles and runs in one call, so a SyntaxError arrives here looking exactly like
|
||||
// an abort, and a script with a typo in it must still say so while a sibling tears the
|
||||
// runtime down.
|
||||
if (calogAbortRaised(context->broker)) {
|
||||
JS_FreeValue(ctx, exc);
|
||||
JS_FreeValue(ctx, val);
|
||||
return calogOkE;
|
||||
|
|
@ -470,13 +472,23 @@ int32_t calogJsRun(CalogJsT *context, const char *source) {
|
|||
}
|
||||
|
||||
|
||||
// The periodic interrupt handler for a time-limited context: retire the context and abort the
|
||||
// running script (non-zero return) once the wall-clock deadline has passed.
|
||||
// The periodic interrupt handler, installed on EVERY context. A non-zero return makes QuickJS
|
||||
// unwind the running script. Two reasons to do that: the runtime was latched aborting (a script's
|
||||
// calogExit, or a signal the runner turned into one), or -- only for a time-limited context -- the
|
||||
// wall-clock deadline passed. The abort check is why this is no longer conditional: a script that
|
||||
// calls no natives at all, a bare `while (true) {}`, is invisible to the dispatch choke point.
|
||||
//
|
||||
// Unlike the budget check it does NOT retire the context: calogAbortAll leaves teardown order to the
|
||||
// host, and a context that closed itself here would strand what its registries still hold.
|
||||
static int jsInterrupt(JSRuntime *rt, void *opaque) {
|
||||
CalogJsT *context;
|
||||
|
||||
(void)rt;
|
||||
context = (CalogJsT *)opaque;
|
||||
if (calogAborting(context->broker)) {
|
||||
calogAbortRaise();
|
||||
return 1;
|
||||
}
|
||||
if (context->limits != NULL && context->limits->deadlineMs != 0 && calogMonotonicMillis() >= context->limits->deadlineMs) {
|
||||
calogCurrentRetire();
|
||||
return 1;
|
||||
|
|
|
|||
|
|
@ -56,7 +56,9 @@ static int32_t luaPushValueDepth(lua_State *L, const CalogValueT *value, in
|
|||
static int32_t luaToValueDepth(lua_State *L, int idx, CalogValueT *out, int32_t depth);
|
||||
static int luaTrampoline(lua_State *L);
|
||||
static int32_t luaTrampolineDispatch(void *userData, CalogValueT *args, int32_t argCount, CalogValueT *result);
|
||||
static void luaTimeHook(lua_State *L, lua_Debug *ar);
|
||||
static void luaOpenSafeLibs(lua_State *L);
|
||||
static int luaTextOnlyLoad(lua_State *L);
|
||||
static void luaVmHook(lua_State *L, lua_Debug *ar);
|
||||
|
||||
|
||||
// Shared marshal/dispatch/cleanup pipeline for both call sites that cross into Lua's
|
||||
|
|
@ -187,13 +189,101 @@ static void *luaCountingAlloc(void *ud, void *ptr, size_t osize, size_t nsize) {
|
|||
}
|
||||
|
||||
|
||||
// The instruction-count hook for a time-limited context: if the wall-clock deadline has passed, ask
|
||||
// the actor layer to retire this context and raise an error out of the running script.
|
||||
static void luaTimeHook(lua_State *L, lua_Debug *ar) {
|
||||
// Open the standard libraries a calog script may have, INSTEAD of luaL_openlibs.
|
||||
//
|
||||
// calog's contract is that a script reaches the host only through registered natives, which is the
|
||||
// one place the allow-list, the memory cap and the wall-clock budget can see it. Lua's io/os/package
|
||||
// libraries walk straight around all three: io.open reads and writes any file, os.execute and
|
||||
// io.popen run a shell, os.exit terminates the whole host process mid-run, and package.loadlib
|
||||
// dlopens an attacker-supplied .so and executes native code inside this process. None of that is
|
||||
// reachable through calogCall, so no policy calog can express applies to it.
|
||||
//
|
||||
// Every capability that is dropped has a native equivalent that IS gated: fs* for files, procRun for
|
||||
// processes, net*/http* for sockets, calogExit for ending the run, time* for clocks. The three
|
||||
// things with no equivalent are os.getenv, os.rename and os.tmpname; a host that wants those should
|
||||
// register a native for them, which is exactly the point -- then they are policy-controlled.
|
||||
//
|
||||
// debug is dropped as well: debug.getregistry hands out every internal table calog keeps there, and
|
||||
// debug.sethook would fight the hook the sandbox installs.
|
||||
static void luaOpenSafeLibs(lua_State *L) {
|
||||
static const luaL_Reg safeLibs[] = {
|
||||
{ LUA_GNAME, luaopen_base },
|
||||
{ LUA_COLIBNAME, luaopen_coroutine },
|
||||
{ LUA_TABLIBNAME, luaopen_table },
|
||||
{ LUA_STRLIBNAME, luaopen_string },
|
||||
{ LUA_MATHLIBNAME, luaopen_math },
|
||||
{ LUA_UTF8LIBNAME, luaopen_utf8 }
|
||||
};
|
||||
size_t index;
|
||||
|
||||
for (index = 0; index < sizeof(safeLibs) / sizeof(safeLibs[0]); index++) {
|
||||
luaL_requiref(L, safeLibs[index].name, safeLibs[index].func, 1);
|
||||
lua_pop(L, 1);
|
||||
}
|
||||
// Base leaves three file-and-bytecode doors open even with io/os/package gone. dofile and
|
||||
// loadfile read and execute any path on disk. string.dump plus load(binary) is a VM escape of a
|
||||
// different kind: hand-crafted bytecode is not verified, so a mutated chunk corrupts memory
|
||||
// inside the host. Dropping dump and forcing load to TEXT mode closes that without taking away
|
||||
// load itself, which scripts legitimately use to compile strings.
|
||||
lua_pushnil(L);
|
||||
lua_setglobal(L, "dofile");
|
||||
lua_pushnil(L);
|
||||
lua_setglobal(L, "loadfile");
|
||||
lua_getglobal(L, LUA_STRLIBNAME);
|
||||
lua_pushnil(L);
|
||||
lua_setfield(L, -2, "dump");
|
||||
lua_pop(L, 1);
|
||||
lua_getglobal(L, "load");
|
||||
lua_pushcclosure(L, luaTextOnlyLoad, 1);
|
||||
lua_setglobal(L, "load");
|
||||
}
|
||||
|
||||
|
||||
// load(chunk [, chunkname [, mode [, env]]]) with mode pinned to "t". The real load is upvalue 1.
|
||||
// Passing the caller's own mode through would defeat the point, so it is replaced rather than
|
||||
// defaulted; everything else is forwarded untouched.
|
||||
static int luaTextOnlyLoad(lua_State *L) {
|
||||
int argc;
|
||||
int passed;
|
||||
|
||||
argc = lua_gettop(L);
|
||||
lua_pushvalue(L, lua_upvalueindex(1));
|
||||
lua_pushvalue(L, 1); // chunk
|
||||
if (argc >= 2) {
|
||||
lua_pushvalue(L, 2); // chunkname; a nil here means "use the default"
|
||||
} else {
|
||||
lua_pushnil(L);
|
||||
}
|
||||
lua_pushliteral(L, "t"); // mode: text only, never a binary chunk
|
||||
passed = 3;
|
||||
// env is forwarded ONLY when the caller actually supplied it: Lua distinguishes an absent 4th
|
||||
// argument from an explicit nil, and an explicit nil would set the chunk's _ENV to nil.
|
||||
if (argc >= 4) {
|
||||
lua_pushvalue(L, 4);
|
||||
passed = 4;
|
||||
}
|
||||
lua_call(L, passed, LUA_MULTRET);
|
||||
return lua_gettop(L) - argc;
|
||||
}
|
||||
|
||||
|
||||
// The instruction-count hook, installed on EVERY context. It answers two questions: has the runtime
|
||||
// been latched aborting (a script's calogExit, or a signal the runner turned into one), and -- only
|
||||
// for a time-limited context -- has the wall-clock deadline passed. The abort check is why the hook
|
||||
// is no longer conditional: a script that calls no natives at all, a bare `while true do end`, is
|
||||
// invisible to the dispatch choke point, so without this Ctrl-C could not touch it.
|
||||
//
|
||||
// Unlike the budget check it does NOT retire the context: calogAbortAll deliberately leaves teardown
|
||||
// order to the host, and a context that closed itself here would strand what its registries hold.
|
||||
static void luaVmHook(lua_State *L, lua_Debug *ar) {
|
||||
CalogLuaT *context;
|
||||
|
||||
(void)ar;
|
||||
context = luaContextOf(L);
|
||||
if (calogAborting(context->broker)) {
|
||||
calogAbortRaise();
|
||||
luaL_error(L, "%s", CALOG_ABORT_MESSAGE);
|
||||
}
|
||||
if (context->limits != NULL && context->limits->deadlineMs != 0 && calogMonotonicMillis() >= context->limits->deadlineMs) {
|
||||
calogCurrentRetire();
|
||||
luaL_error(L, "context exceeded its time budget");
|
||||
|
|
@ -218,16 +308,16 @@ int32_t calogLuaCreate(CalogLuaT **out, CalogT *broker, uint64_t ctxId, CalogLim
|
|||
free(context);
|
||||
return calogErrOomE;
|
||||
}
|
||||
luaL_openlibs(L);
|
||||
luaOpenSafeLibs(L);
|
||||
context->L = L;
|
||||
context->broker = broker;
|
||||
context->ctxId = ctxId;
|
||||
context->limits = limits;
|
||||
*(CalogLuaT **)lua_getextraspace(L) = context;
|
||||
// A time-limited context gets an instruction-count hook that checks the wall-clock deadline.
|
||||
if (limits != NULL && limits->deadlineMs > 0) {
|
||||
lua_sethook(L, luaTimeHook, LUA_MASKCOUNT, 1000);
|
||||
}
|
||||
// Every context gets the instruction-count hook: it carries the abort check, which an unlimited
|
||||
// context needs just as much as a limited one (see luaVmHook). One atomic load per 1000 VM
|
||||
// instructions is the whole cost.
|
||||
lua_sethook(L, luaVmHook, LUA_MASKCOUNT, 1000);
|
||||
|
||||
luaL_newmetatable(L, LUA_FUNCTION_META);
|
||||
lua_pushcfunction(L, luaFunctionCall);
|
||||
|
|
@ -471,9 +561,10 @@ 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)) {
|
||||
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, so report
|
||||
// nothing. Asked as "is this error ours": a genuine runtime error still reports, even
|
||||
// while a sibling is tearing the runtime down. (Lua's load error is reported above.)
|
||||
if (calogAbortRaised(context->broker)) {
|
||||
lua_pop(L, 1);
|
||||
return calogOkE;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,13 +162,24 @@ static void mrubyCodeFetchHook(mrb_state *mrb, const struct mrb_irep *irep, cons
|
|||
(void)pc;
|
||||
(void)regs;
|
||||
context = (CalogMrubyT *)mrb->ud;
|
||||
if (context == NULL || context->limits == NULL || context->limits->deadlineMs == 0) {
|
||||
if (context == NULL) {
|
||||
return;
|
||||
}
|
||||
// Both checks are amortized over MRUBY_STEP_MASK+1 instructions, so the per-instruction cost
|
||||
// stays a counter bump whether the context is limited or not.
|
||||
context->stepCounter++;
|
||||
if ((context->stepCounter & MRUBY_STEP_MASK) != 0) {
|
||||
return;
|
||||
}
|
||||
// The runtime was latched aborting (calogExit, or a signal): unwind this script even though it
|
||||
// may never call a native. Deliberately no retire -- teardown order stays the host's.
|
||||
if (calogAborting(context->broker)) {
|
||||
calogAbortRaise();
|
||||
mrb_raise(mrb, E_RUNTIME_ERROR, CALOG_ABORT_MESSAGE);
|
||||
}
|
||||
if (context->limits == NULL || context->limits->deadlineMs == 0) {
|
||||
return;
|
||||
}
|
||||
if (calogMonotonicMillis() >= context->limits->deadlineMs) {
|
||||
calogCurrentRetire();
|
||||
mrb_raise(mrb, E_RUNTIME_ERROR, "context exceeded its time budget");
|
||||
|
|
@ -202,9 +213,9 @@ int32_t calogMrubyCreate(CalogMrubyT **out, CalogT *broker, uint64_t ctxId, Calo
|
|||
if (limits != NULL && limits->memCap > 0) {
|
||||
gMrubyLimits = limits;
|
||||
}
|
||||
if (limits != NULL && limits->deadlineMs > 0) {
|
||||
mrb->code_fetch_hook = mrubyCodeFetchHook;
|
||||
}
|
||||
// Installed on every context: the hook carries the abort check, which an unlimited context needs
|
||||
// just as much as a limited one (see mrubyCodeFetchHook).
|
||||
mrb->code_fetch_hook = mrubyCodeFetchHook;
|
||||
// Ruby ergonomics without pulling mruby-io: puts/print/p route to stdout. Defined on Kernel
|
||||
// so a bare `puts "x"` works at top level (calog scripts still use fs*/net* for real IO).
|
||||
mrb_define_method(mrb, mrb->kernel_module, "puts", mrubyPuts, MRB_ARGS_ANY());
|
||||
|
|
@ -639,10 +650,12 @@ 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)) {
|
||||
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, so report
|
||||
// nothing -- and do not run inspect, which would re-enter a VM whose every native call is
|
||||
// now refused. Asked as "is this exception ours" rather than "is the runtime aborting":
|
||||
// mrb_load_string parses and runs in one call, so a SyntaxError would otherwise be dropped
|
||||
// silently whenever a sibling was tearing the runtime down.
|
||||
if (calogAbortRaised(context->broker)) {
|
||||
mrb->exc = NULL;
|
||||
mrb_gc_arena_restore(mrb, arena);
|
||||
return calogOkE;
|
||||
|
|
|
|||
|
|
@ -1627,7 +1627,18 @@ static int mbStepHandler(struct mb_interpreter_t *s, void **l, const char *file,
|
|||
userData = NULL;
|
||||
mb_get_userdata(s, &userData);
|
||||
context = (CalogMyBasicT *)userData;
|
||||
if (context == NULL || context->limits == NULL) {
|
||||
if (context == NULL) {
|
||||
return MB_FUNC_OK;
|
||||
}
|
||||
// The runtime was latched aborting (calogExit, or a signal): unwind this script even though it
|
||||
// may never call a native. my-basic has no "aborted" status of its own, and the adapter swallows
|
||||
// this error anyway (calogAbortRaised), so the code carried here is never seen by anyone.
|
||||
// Deliberately no retire -- teardown order stays the host's.
|
||||
if (calogAborting(context->broker)) {
|
||||
calogAbortRaise();
|
||||
return mb_raise_error(s, l, SE_RN_PROGRAM_TOO_LONG, MB_FUNC_ERR);
|
||||
}
|
||||
if (context->limits == NULL) {
|
||||
return MB_FUNC_OK;
|
||||
}
|
||||
if (context->limits->memCap > 0 && context->limits->memUsed > context->limits->memCap) {
|
||||
|
|
@ -1800,9 +1811,9 @@ int32_t calogMyBasicCreate(CalogMyBasicT **out, CalogT *broker, uint64_t ctxId,
|
|||
mb_set_inputer(bas, mbInputer);
|
||||
// A limited context checks its memory and time budgets at every statement boundary. Unlimited
|
||||
// contexts install nothing, so the step hook adds no per-statement cost to the common case.
|
||||
if (limits != NULL) {
|
||||
mb_debug_set_stepped_handler(bas, mbStepHandler, NULL);
|
||||
}
|
||||
// Installed for every context: the hook carries the abort check, which an unlimited context needs
|
||||
// just as much as a limited one (see mbStepHandler).
|
||||
mb_debug_set_stepped_handler(bas, mbStepHandler, NULL);
|
||||
// A host callable handed into a script (mbFromValueDepth's calogFnE case) becomes a
|
||||
// usertype-ref; calogInvoke(fn, ...args) is how a BASIC script calls it.
|
||||
mb_register_func(bas, "calogInvoke", mbForeignInvokeNative);
|
||||
|
|
@ -1911,9 +1922,10 @@ 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)) {
|
||||
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, so report
|
||||
// nothing. Asked as "is this error ours": a genuine runtime error still reports, even
|
||||
// while a sibling is tearing the runtime down. (A load error is reported above.)
|
||||
if (calogAbortRaised(context->broker)) {
|
||||
return calogOkE;
|
||||
}
|
||||
mbReportError(context, "run");
|
||||
|
|
|
|||
|
|
@ -540,9 +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)) {
|
||||
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, so report
|
||||
// nothing. Asked as "is this error ours" rather than "is the runtime aborting": the catch
|
||||
// wrapper reports a read error and a run error through the same errorFlag, so an unbalanced
|
||||
// paren would otherwise vanish whenever a sibling was tearing the runtime down.
|
||||
if (calogAbortRaised(context->broker)) {
|
||||
return calogOkE;
|
||||
}
|
||||
fprintf(stderr, "s7 error: %s\n", context->errorText);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,18 @@ struct CalogSquirrelT {
|
|||
// this thread-local (each context owns its thread) -- the pattern my-basic uses for its global
|
||||
// allocator. The three helpers below are the C-linkage functions the vendored patches call.
|
||||
static _Thread_local CalogLimitStateT *gSquirrelLimits = NULL;
|
||||
// The broker of the Squirrel context on this thread. Armed for EVERY context, limited or not,
|
||||
// because the Execute-loop poll uses it to see the runtime abort latch -- and an unlimited script
|
||||
// must be interruptible too.
|
||||
static _Thread_local CalogT *gSquirrelBroker = NULL;
|
||||
|
||||
// Why the patched Execute loop must unwind. 1 is set by the counting allocator (sqmem.cpp); the
|
||||
// others come from calogSquirrelPoll. The loop only tests these against 1 (the one reason it
|
||||
// re-verifies) and asks calogSquirrelStopMessage for the text.
|
||||
#define SQ_STOP_NONE 0
|
||||
#define SQ_STOP_MEMORY 1
|
||||
#define SQ_STOP_TIME 2
|
||||
#define SQ_STOP_ABORT 3
|
||||
|
||||
// Shared shape for the two things a marshalled call can dispatch to: a CalogFnT
|
||||
// (calogFnInvoke) or a broker binding (calogCall). squirrelCallDispatch runs the
|
||||
|
|
@ -200,21 +212,54 @@ int calogSquirrelMemCharge(long long delta) {
|
|||
|
||||
|
||||
// Called from the Execute loop to re-verify a memory trip: true only if still over the cap (so a
|
||||
// transient spike that was freed within the same opcode does not retire the context).
|
||||
// transient spike that was freed within the same opcode does not retire the context). Retires here,
|
||||
// since the confirmation is what makes the trip real.
|
||||
int calogSquirrelMemExceeded(void) {
|
||||
CalogLimitStateT *limits;
|
||||
|
||||
limits = gSquirrelLimits;
|
||||
return (limits != NULL && limits->memCap > 0 && limits->memUsed > limits->memCap) ? 1 : 0;
|
||||
if (limits != NULL && limits->memCap > 0 && limits->memUsed > limits->memCap) {
|
||||
calogCurrentRetire();
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
// Called from the Execute loop's periodic poll: true once the wall-clock deadline has passed.
|
||||
int calogSquirrelTimeExpired(void) {
|
||||
// The Execute loop's periodic poll (every 1024 opcodes): returns the reason the running script must
|
||||
// stop, having already done the bookkeeping that reason calls for.
|
||||
//
|
||||
// The abort check is first and is NOT conditional on limits: a script that calls no natives -- a
|
||||
// bare `while(true){}` -- never reaches the dispatch choke point, so this poll is the only thing
|
||||
// that can stop it. It deliberately does not retire the context; calogAbortAll leaves teardown
|
||||
// order to the host, and a context that closed itself here would strand what its registries hold.
|
||||
int calogSquirrelPoll(void) {
|
||||
CalogLimitStateT *limits;
|
||||
|
||||
if (gSquirrelBroker != NULL && calogAborting(gSquirrelBroker)) {
|
||||
calogAbortRaise();
|
||||
return SQ_STOP_ABORT;
|
||||
}
|
||||
limits = gSquirrelLimits;
|
||||
return (limits != NULL && limits->deadlineMs != 0 && calogMonotonicMillis() >= limits->deadlineMs) ? 1 : 0;
|
||||
if (limits != NULL && limits->deadlineMs != 0 && calogMonotonicMillis() >= limits->deadlineMs) {
|
||||
calogCurrentRetire();
|
||||
return SQ_STOP_TIME;
|
||||
}
|
||||
return SQ_STOP_NONE;
|
||||
}
|
||||
|
||||
|
||||
// The message the patched Execute loop raises for a given stop code. Kept here rather than in
|
||||
// sqvm.cpp so the abort text has exactly one definition (CALOG_ABORT_MESSAGE), shared with every
|
||||
// other engine.
|
||||
const SQChar *calogSquirrelStopMessage(int code) {
|
||||
if (code == SQ_STOP_ABORT) {
|
||||
return _SC(CALOG_ABORT_MESSAGE);
|
||||
}
|
||||
if (code == SQ_STOP_TIME) {
|
||||
return _SC("context exceeded its time budget");
|
||||
}
|
||||
return _SC("context exceeded its memory budget");
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -242,6 +287,7 @@ int32_t calogSquirrelCreate(CalogSquirrelT **out, CalogT *broker, uint64_t ctxId
|
|||
if (limits != NULL && (limits->memCap > 0 || limits->deadlineMs > 0)) {
|
||||
gSquirrelLimits = limits;
|
||||
}
|
||||
gSquirrelBroker = broker;
|
||||
sq_setforeignptr(v, context);
|
||||
sq_setprintfunc(v, squirrelPrint, squirrelErrorPrint);
|
||||
sq_setcompilererrorhandler(v, squirrelCompileError);
|
||||
|
|
@ -268,6 +314,7 @@ void calogSquirrelDestroy(CalogSquirrelT *context) {
|
|||
// the limit was armed) is uncharged, so charging its frees here would drive memUsed negative.
|
||||
// The limit pointer aliases the context's limit state, which is freed with the context anyway.
|
||||
gSquirrelLimits = NULL;
|
||||
gSquirrelBroker = NULL;
|
||||
if (context->v != NULL) {
|
||||
sq_close(context->v);
|
||||
}
|
||||
|
|
@ -528,9 +575,10 @@ 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)) {
|
||||
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, so report
|
||||
// nothing. Asked as "is this error ours": a genuine runtime error still reports, even
|
||||
// while a sibling is tearing the runtime down. (A compile error is reported above.)
|
||||
if (calogAbortRaised(context->broker)) {
|
||||
sq_settop(v, baseTop);
|
||||
return calogOkE;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@
|
|||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
// How often the time-limit handler re-arms itself, and so how often a running Tcl script notices
|
||||
// the runtime abort latch. Also the ceiling on any real budget's re-arm.
|
||||
#define TCL_ABORT_POLL_MS 100
|
||||
|
||||
struct CalogTclT {
|
||||
Tcl_Interp *interp;
|
||||
CalogT *broker;
|
||||
|
|
@ -70,6 +74,7 @@ static const Tcl_ObjType *gByteArrayType = NULL;
|
|||
static const Tcl_ObjType *gListType = NULL;
|
||||
static const Tcl_ObjType *gDictType = NULL;
|
||||
|
||||
static void tclArmLimit(Tcl_Interp *ip, uint64_t ms);
|
||||
static int tclCallbackCmd(void *cd, Tcl_Interp *ip, int objc, Tcl_Obj *const objv[]);
|
||||
static int tclForeignCmd(void *cd, Tcl_Interp *ip, int objc, Tcl_Obj *const objv[]);
|
||||
static void tclForeignDelete(void *cd);
|
||||
|
|
@ -83,6 +88,7 @@ static int tclPrintCmd(void *cd, Tcl_Interp *ip, int objc, Tcl_Obj *const o
|
|||
static int tclPutsCmd(void *cd, Tcl_Interp *ip, int objc, Tcl_Obj *const objv[]);
|
||||
static int32_t tclScriptInvoke(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
|
||||
static void tclScriptRelease(CalogFnT *callable);
|
||||
static void tclSealHostAccess(Tcl_Interp *ip);
|
||||
static int tclSetResult(CalogTclT *context, Tcl_Interp *ip, int32_t status, CalogValueT *result);
|
||||
static void tclTimeLimit(void *cd, Tcl_Interp *ip);
|
||||
static int32_t tclToValue(CalogTclT *context, Tcl_Obj *obj, CalogValueT *out, int32_t depth);
|
||||
|
|
@ -91,6 +97,62 @@ static int tclUnknownCmd(void *cd, Tcl_Interp *ip, int objc, Tcl_Obj *const
|
|||
static int32_t tclWrapCommand(CalogTclT *context, Tcl_Obj *prefix, CalogFnT **out);
|
||||
|
||||
|
||||
// Shut every door out of the interpreter that does not go through a calog native.
|
||||
//
|
||||
// calog's contract is that a script reaches the host only through registered natives, where the
|
||||
// allow-list, the memory cap and the wall-clock budget can see it. Tcl's core carries a complete
|
||||
// operating-system interface that bypasses all of it: exec and `open |cmd` run a shell, open reads
|
||||
// and writes any path, socket opens the network, load dlopens an arbitrary .so into this process,
|
||||
// and exit terminates the host mid-run around calogExit's ordered teardown.
|
||||
//
|
||||
// Deleting the visible command is NOT enough for an ensemble: `rename file {}` leaves the
|
||||
// ::tcl::file::* implementation commands callable by their fully-qualified names, and the same holds
|
||||
// for zipfs and ::tcl::process. Those namespaces are swept explicitly. The sweep is a Tcl script
|
||||
// rather than a list of Tcl_DeleteCommand calls because it has to enumerate what is actually
|
||||
// present -- ensemble membership varies by build and platform.
|
||||
//
|
||||
// The standard channels are unregistered exactly as Tcl's own make-safe path does, but that alone is
|
||||
// not containment: Tcl_GetChannel re-resolves the NAMES stdin/stdout/stderr through Tcl_GetStdChannel
|
||||
// even after unregistering, so `chan puts stdout` still reached the host's stdout (verified). The
|
||||
// channel command set therefore goes as well -- with open and socket gone it can only ever have
|
||||
// named a standard channel anyway, and reading the host's stdin is not something a script here
|
||||
// should do. calog's puts/print are the adapter's own commands writing through C rather than a Tcl
|
||||
// channel, so they keep working.
|
||||
//
|
||||
// Everything dropped has a gated equivalent: fs* for files, procRun for processes, net*/http* for
|
||||
// sockets, calogExit for ending the run, time*/timer* for delays. Pure computation -- string, list,
|
||||
// dict, expr, regexp, proc -- is untouched.
|
||||
static void tclSealHostAccess(Tcl_Interp *ip) {
|
||||
static const char *const sealScript =
|
||||
"foreach c {exec open socket load unload exit source glob cd pwd zipfs after vwait update"
|
||||
" fcopy fileevent file chan gets read seek tell eof flush close fblocked fconfigure} {\n"
|
||||
" if {[llength [info commands ::$c]]} { rename ::$c {} }\n"
|
||||
"}\n"
|
||||
"foreach ns {::tcl::file ::tcl::zipfs ::tcl::process} {\n"
|
||||
" foreach c [info commands ${ns}::*] { rename $c {} }\n"
|
||||
"}\n"
|
||||
"foreach c {::tcl::info::nameofexecutable ::tcl::encoding::system ::tcl::encoding::dirs} {\n"
|
||||
" if {[llength [info commands $c]]} { rename $c {} }\n"
|
||||
"}\n"
|
||||
"catch {unset ::env}\n";
|
||||
static const int stdChannels[] = { TCL_STDIN, TCL_STDOUT, TCL_STDERR };
|
||||
size_t index;
|
||||
|
||||
for (index = 0; index < sizeof(stdChannels) / sizeof(stdChannels[0]); index++) {
|
||||
Tcl_Channel channel;
|
||||
|
||||
channel = Tcl_GetStdChannel(stdChannels[index]);
|
||||
if (channel != NULL) {
|
||||
Tcl_UnregisterChannel(ip, channel);
|
||||
}
|
||||
}
|
||||
if (Tcl_EvalEx(ip, sealScript, -1, TCL_EVAL_GLOBAL) != TCL_OK) {
|
||||
fprintf(stderr, "calog: tcl sandbox seal failed: %s\n", Tcl_GetString(Tcl_GetObjResult(ip)));
|
||||
}
|
||||
Tcl_ResetResult(ip);
|
||||
}
|
||||
|
||||
|
||||
int32_t calogTclCreate(CalogTclT **out, CalogT *broker, uint64_t ctxId, CalogLimitStateT *limits) {
|
||||
CalogTclT *context;
|
||||
|
||||
|
|
@ -105,6 +167,7 @@ int32_t calogTclCreate(CalogTclT **out, CalogT *broker, uint64_t ctxId, CalogLim
|
|||
free(context);
|
||||
return calogErrOomE;
|
||||
}
|
||||
tclSealHostAccess(context->interp);
|
||||
context->broker = broker;
|
||||
context->ctxId = ctxId;
|
||||
context->limits = limits;
|
||||
|
|
@ -119,25 +182,14 @@ int32_t calogTclCreate(CalogTclT **out, CalogT *broker, uint64_t ctxId, CalogLim
|
|||
gTclMemAsync = context->memAsync;
|
||||
gTclLimits = limits;
|
||||
}
|
||||
if (limits != NULL && limits->deadlineMs > 0) {
|
||||
Tcl_Time t;
|
||||
uint64_t nowMs;
|
||||
uint64_t remainMs;
|
||||
|
||||
nowMs = calogMonotonicMillis();
|
||||
remainMs = limits->deadlineMs > nowMs ? limits->deadlineMs - nowMs : 0;
|
||||
Tcl_GetTime(&t);
|
||||
t.sec += (long long)(remainMs / 1000);
|
||||
t.usec += (long)((remainMs % 1000) * 1000);
|
||||
if (t.usec >= 1000000) {
|
||||
t.sec += 1;
|
||||
t.usec -= 1000000;
|
||||
}
|
||||
Tcl_LimitSetTime(context->interp, &t);
|
||||
Tcl_LimitTypeSet(context->interp, TCL_LIMIT_TIME);
|
||||
Tcl_LimitSetGranularity(context->interp, TCL_LIMIT_TIME, 1);
|
||||
Tcl_LimitAddHandler(context->interp, TCL_LIMIT_TIME, tclTimeLimit, context, NULL);
|
||||
}
|
||||
// Every context arms the time limit, because Tcl's limit handler is also the only place this
|
||||
// engine can notice the runtime abort latch from inside a running script. The handler re-arms
|
||||
// itself (see tclTimeLimit), so for an unlimited context the limit is simply a poll that never
|
||||
// fires as a limit -- what expires is only ever the poll interval.
|
||||
tclArmLimit(context->interp, TCL_ABORT_POLL_MS);
|
||||
Tcl_LimitTypeSet(context->interp, TCL_LIMIT_TIME);
|
||||
Tcl_LimitSetGranularity(context->interp, TCL_LIMIT_TIME, 1);
|
||||
Tcl_LimitAddHandler(context->interp, TCL_LIMIT_TIME, tclTimeLimit, context, NULL);
|
||||
Tcl_SetAssocData(context->interp, "calogCtx", NULL, context); // recovered by tclOf
|
||||
// puts/print write to stdout directly: Tcl's channel-based puts needs the IO subsystem calog
|
||||
// does not initialize. calogCallback wraps a command prefix into a callable function value.
|
||||
|
|
@ -207,28 +259,49 @@ static int tclMemAsync(void *cd, Tcl_Interp *ip, int code) {
|
|||
static void tclTimeLimit(void *cd, Tcl_Interp *ip) {
|
||||
CalogTclT *context;
|
||||
uint64_t nowMs;
|
||||
uint64_t remainMs;
|
||||
|
||||
context = (CalogTclT *)cd;
|
||||
if (context->limits == NULL || context->limits->deadlineMs == 0) {
|
||||
// The runtime was latched aborting (calogExit, or a signal): return WITHOUT pushing the limit
|
||||
// forward, so Tcl finds it still exceeded and unwinds the script. This is the only way an
|
||||
// interrupt reaches a Tcl script that calls no natives. Deliberately no retire -- teardown order
|
||||
// stays the host's.
|
||||
if (calogAborting(context->broker)) {
|
||||
calogAbortRaise();
|
||||
return;
|
||||
}
|
||||
nowMs = calogMonotonicMillis();
|
||||
if (nowMs >= context->limits->deadlineMs) {
|
||||
calogCurrentRetire();
|
||||
} else {
|
||||
Tcl_Time t;
|
||||
uint64_t remainMs;
|
||||
|
||||
remainMs = context->limits->deadlineMs - nowMs;
|
||||
Tcl_GetTime(&t);
|
||||
t.sec += (long long)(remainMs / 1000);
|
||||
t.usec += (long)((remainMs % 1000) * 1000);
|
||||
if (t.usec >= 1000000) {
|
||||
t.sec += 1;
|
||||
t.usec -= 1000000;
|
||||
if (context->limits != NULL && context->limits->deadlineMs != 0) {
|
||||
if (nowMs >= context->limits->deadlineMs) {
|
||||
calogCurrentRetire();
|
||||
return;
|
||||
}
|
||||
Tcl_LimitSetTime(ip, &t);
|
||||
remainMs = context->limits->deadlineMs - nowMs;
|
||||
} else {
|
||||
remainMs = TCL_ABORT_POLL_MS;
|
||||
}
|
||||
// Never push the limit further out than one poll interval, so the abort check above keeps
|
||||
// running on a long budget instead of going quiet until the deadline.
|
||||
if (remainMs > TCL_ABORT_POLL_MS) {
|
||||
remainMs = TCL_ABORT_POLL_MS;
|
||||
}
|
||||
tclArmLimit(ip, remainMs);
|
||||
}
|
||||
|
||||
|
||||
// Set this interp's time limit to now + ms. Shared by the arming in calogTclCreate and the re-arm in
|
||||
// tclTimeLimit, which had grown the same Tcl_Time arithmetic twice.
|
||||
static void tclArmLimit(Tcl_Interp *ip, uint64_t ms) {
|
||||
Tcl_Time t;
|
||||
|
||||
Tcl_GetTime(&t);
|
||||
t.sec += (long long)(ms / 1000);
|
||||
t.usec += (long)((ms % 1000) * 1000);
|
||||
if (t.usec >= 1000000) {
|
||||
t.sec += 1;
|
||||
t.usec -= 1000000;
|
||||
}
|
||||
Tcl_LimitSetTime(ip, &t);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -845,9 +918,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)) {
|
||||
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, so report
|
||||
// nothing. Asked as "is this error ours" rather than "is the runtime aborting": Tcl_EvalEx
|
||||
// parses and runs in one call and reports both as TCL_ERROR, so a malformed script would
|
||||
// otherwise vanish without a word whenever a sibling was tearing the runtime down.
|
||||
if (calogAbortRaised(context->broker)) {
|
||||
return calogOkE;
|
||||
}
|
||||
fprintf(stderr, "tcl error: %s\n", Tcl_GetString(Tcl_GetObjResult(context->interp)));
|
||||
|
|
|
|||
40
src/value.c
40
src/value.c
|
|
@ -27,6 +27,11 @@ struct CalogFnT {
|
|||
uint64_t ownerCtxId; // the owning context's 64-bit id (0 = host)
|
||||
_Atomic int32_t refCount;
|
||||
_Atomic bool alive;
|
||||
// Set by calogFnReclaim once the owner has run the engine release, so a later finalize does not
|
||||
// run it again. Atomic, and the ONLY thing reclaim changes: fn/userData/release are written once
|
||||
// at create and read by other threads (actorInvokeCallable marshals them), so mutating those
|
||||
// would be a data race against an invoke already in flight.
|
||||
_Atomic bool reclaimed;
|
||||
};
|
||||
|
||||
static int32_t aggregateCopyDepth(CalogAggT **out, const CalogAggT *src, int32_t depth);
|
||||
|
|
@ -205,6 +210,7 @@ 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);
|
||||
atomic_init(&callable->reclaimed, false);
|
||||
// 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.
|
||||
|
|
@ -243,7 +249,7 @@ void calogFnFinalize(CalogFnT *callable) {
|
|||
// 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->release != NULL && !atomic_exchange_explicit(&callable->reclaimed, true, memory_order_acq_rel)) {
|
||||
if (callable->ownerCtxId == CALOG_HOST_ID || calogContextRegistered(callable->runtime, callable->ownerCtxId)) {
|
||||
callable->release(callable);
|
||||
} else {
|
||||
|
|
@ -265,10 +271,13 @@ void calogFnReclaim(CalogFnT *callable) {
|
|||
return;
|
||||
}
|
||||
calogFnMarkDead(callable);
|
||||
if (callable->release != NULL) {
|
||||
// Claim the release with an atomic exchange so it runs exactly once, here or in a finalize --
|
||||
// never both. Nothing else about the callable changes: an invoke already past its alive check
|
||||
// may still be reading fn/userData on another thread to marshal this call, and those reads must
|
||||
// not race a write. (That marshal cannot land: reclaim runs after serveLoop has closed this
|
||||
// context's queue, so the dispatch is refused -- see sec 28.)
|
||||
if (callable->release != NULL && !atomic_exchange_explicit(&callable->reclaimed, true, memory_order_acq_rel)) {
|
||||
callable->release(callable); // frees the engine handle AND the adapter's userData block
|
||||
callable->release = NULL;
|
||||
callable->userData = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -318,13 +327,34 @@ void calogFnFinalizeForeign(CalogFnT *callable) {
|
|||
return;
|
||||
}
|
||||
calogContextUntrackFn(callable->runtime, callable->ownerCtxId, callable);
|
||||
if (callable->release != NULL) {
|
||||
if (callable->release != NULL && !atomic_exchange_explicit(&callable->reclaimed, true, memory_order_acq_rel)) {
|
||||
free(callable->userData);
|
||||
}
|
||||
free(callable);
|
||||
}
|
||||
|
||||
|
||||
// Weak-to-strong upgrade: take a reference ONLY if this callable has not already dropped to zero.
|
||||
// A plain calogFnRetain cannot be used to adopt a callable found in a list, because the count
|
||||
// reaching zero is what COMMITS calogFnRelease to finalizing it -- retaining after that resurrects a
|
||||
// corpse the in-flight finalize is about to free, and the second drop would free it twice. The CAS
|
||||
// loop refuses exactly that case. The caller must hold the owner context's queueMutex, which is what
|
||||
// keeps the shell itself alive to be inspected: every path that frees one (calogFnFinalize,
|
||||
// calogFnFinalizeForeign) untracks first, and untrack needs that lock.
|
||||
bool calogFnRetainIfLive(CalogFnT *callable) {
|
||||
int32_t current;
|
||||
|
||||
current = atomic_load_explicit(&callable->refCount, memory_order_acquire);
|
||||
while (current > 0) {
|
||||
if (atomic_compare_exchange_weak_explicit(&callable->refCount, ¤t, current + 1,
|
||||
memory_order_acq_rel, memory_order_acquire)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
void calogFnMarkDead(CalogFnT *callable) {
|
||||
if (callable == NULL) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@
|
|||
|
||||
#define WREN_MAX_CALL_ARITY 16
|
||||
|
||||
// Why the patched bytecode loop must unwind the running script. The loop itself never interprets
|
||||
// these -- it only tests for non-zero and asks calogWrenStopMessage for the text -- so the meanings
|
||||
// stay here with the policy that produces them.
|
||||
#define WREN_STOP_NONE 0
|
||||
#define WREN_STOP_MEMORY 1
|
||||
#define WREN_STOP_TIME 2
|
||||
#define WREN_STOP_ABORT 3
|
||||
|
||||
struct CalogWrenT {
|
||||
WrenVM *vm;
|
||||
CalogT *broker;
|
||||
|
|
@ -35,33 +43,58 @@ struct CalogWrenT {
|
|||
|
||||
|
||||
// Called from the patched Wren bytecode loop (CASE_CODE(LOOP) in wren.c) once per loop iteration.
|
||||
// Returns 1 if the context's live allocation (Wren's own vm->bytesAllocated) is over its memory cap,
|
||||
// 2 if the wall-clock deadline has passed, else 0. The context rides on the VM's userData, so no
|
||||
// thread-local is needed; unlimited contexts return 0 after two loads. memUsed mirrors the VM's
|
||||
// byte count for reporting. The deadline is read every iteration (not amortized) so a slow loop is
|
||||
// still retired near its budget; only limited contexts reach the clock read.
|
||||
// Returns one of WREN_STOP_* -- non-zero meaning "unwind this script" -- and does the bookkeeping
|
||||
// each reason calls for before returning, so the vendored loop only has to raise the message
|
||||
// calogWrenStopMessage hands back. The context rides on the VM's userData, so no thread-local is
|
||||
// needed. memUsed mirrors the VM's byte count for reporting. The deadline is read every iteration
|
||||
// (not amortized) so a slow loop is still retired near its budget; only limited contexts reach the
|
||||
// clock read, and an unlimited one costs a load and an atomic read of the abort latch.
|
||||
//
|
||||
// The abort check comes first and is NOT conditional on limits: a script that calls no natives at
|
||||
// all -- a bare `while (true) {}` -- is invisible to the dispatch choke point, so this is the only
|
||||
// thing that can stop it. It deliberately does not retire the context; calogAbortAll leaves teardown
|
||||
// order to the host, and a context that closed itself here would strand what its registries hold.
|
||||
int calogWrenLoopCheck(void *userData, size_t bytesAllocated) {
|
||||
CalogWrenT *context;
|
||||
CalogLimitStateT *limits;
|
||||
|
||||
context = (CalogWrenT *)userData;
|
||||
if (context == NULL) {
|
||||
return 0;
|
||||
return WREN_STOP_NONE;
|
||||
}
|
||||
if (calogAborting(context->broker)) {
|
||||
calogAbortRaise();
|
||||
return WREN_STOP_ABORT;
|
||||
}
|
||||
limits = context->limits;
|
||||
if (limits == NULL) {
|
||||
return 0;
|
||||
return WREN_STOP_NONE;
|
||||
}
|
||||
if (limits->memCap > 0) {
|
||||
limits->memUsed = (int64_t)bytesAllocated;
|
||||
if ((int64_t)bytesAllocated > limits->memCap) {
|
||||
return 1;
|
||||
calogCurrentRetire();
|
||||
return WREN_STOP_MEMORY;
|
||||
}
|
||||
}
|
||||
if (limits->deadlineMs != 0 && calogMonotonicMillis() >= limits->deadlineMs) {
|
||||
return 2;
|
||||
calogCurrentRetire();
|
||||
return WREN_STOP_TIME;
|
||||
}
|
||||
return 0;
|
||||
return WREN_STOP_NONE;
|
||||
}
|
||||
|
||||
|
||||
// The message the patched loop raises for a given stop code. Kept here rather than in wren.c so the
|
||||
// abort text has exactly one definition (CALOG_ABORT_MESSAGE) shared with every other engine.
|
||||
const char *calogWrenStopMessage(int code) {
|
||||
if (code == WREN_STOP_ABORT) {
|
||||
return CALOG_ABORT_MESSAGE;
|
||||
}
|
||||
if (code == WREN_STOP_TIME) {
|
||||
return "context exceeded its time budget";
|
||||
}
|
||||
return "context exceeded its memory budget";
|
||||
}
|
||||
|
||||
// Backs a CalogFnT exported from this VM: the owning context and the retained function
|
||||
|
|
@ -566,10 +599,10 @@ 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)) {
|
||||
// calogExit (calogAbortAll) unwound this script deliberately: not a failure, so report
|
||||
// nothing. Asked as "is this error ours", which can only be true of a script that
|
||||
// actually ran, so a syntax error still gets its diagnostic even mid-teardown.
|
||||
if (calogAbortRaised(context->broker)) {
|
||||
free(context->errorMsg);
|
||||
context->errorMsg = NULL;
|
||||
return calogOkE;
|
||||
|
|
|
|||
|
|
@ -43,6 +43,18 @@ typedef struct EngineCaseT {
|
|||
const char *source;
|
||||
} EngineCaseT;
|
||||
|
||||
// One engine's "hand it something broken" case. `reports` is whether that engine can still produce a
|
||||
// diagnostic once the runtime is latched aborting. It can when its PARSER rejects the source before
|
||||
// any instruction runs; it cannot when the engine only discovers the problem by running the script,
|
||||
// because the interpreter hook unwinds it first. That is a property of the engine's architecture,
|
||||
// not a policy choice -- see the table in main.
|
||||
typedef struct BrokenCaseT {
|
||||
const CalogEngineT *engine;
|
||||
const char *name;
|
||||
const char *source;
|
||||
bool reports;
|
||||
} BrokenCaseT;
|
||||
|
||||
static _Atomic int32_t markCount = 0;
|
||||
static _Atomic int32_t errorCount = 0;
|
||||
static int32_t testsRun = 0;
|
||||
|
|
@ -53,6 +65,7 @@ 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 runBrokenCase(const BrokenCaseT *item);
|
||||
static void runCase(const EngineCaseT *item);
|
||||
static void testLatchIsPerRuntime(void);
|
||||
|
||||
|
|
@ -112,6 +125,63 @@ static void onError(uint64_t contextId, const char *message, void *userData) {
|
|||
}
|
||||
|
||||
|
||||
// The other half of the contract: an abort must swallow only the errors it CAUSED. Here the runtime
|
||||
// is latched aborting BEFORE a script with a syntax error is handed to the engine -- the exact
|
||||
// window a real run hits when one script calls calogExit while another is still being loaded. The
|
||||
// script did not fail because of the abort; it was broken to begin with, and its author needs to be
|
||||
// told so. Checked on all ten engines: the four whose eval API compiles and runs in a single call
|
||||
// (QuickJS, Tcl, mruby, s7) could not tell the two apart and silently ate the diagnostic, while the
|
||||
// engines that split load from run were already reporting it. See BrokenCaseT for the one engine
|
||||
// that cannot report here at all, and why.
|
||||
static void runBrokenCase(const BrokenCaseT *item) {
|
||||
CalogT *calog;
|
||||
CalogContextT *ctx;
|
||||
CalogValueT result;
|
||||
struct timespec tick = { 0, PUMP_INTERVAL_NS };
|
||||
int32_t index;
|
||||
|
||||
atomic_store(&errorCount, 0);
|
||||
|
||||
calog = calogCreate();
|
||||
if (calog == NULL) {
|
||||
checkEngine(false, item->name, "runtime create failed", __LINE__);
|
||||
return;
|
||||
}
|
||||
calogSetErrorHandler(calog, onError, NULL);
|
||||
|
||||
ctx = calogContextOpen(calog, item->engine);
|
||||
if (ctx == NULL) {
|
||||
checkEngine(false, item->name, "context open failed", __LINE__);
|
||||
calogDestroy(calog);
|
||||
return;
|
||||
}
|
||||
// Latch first: everything after this runs in the teardown window where the diagnostic used to
|
||||
// disappear. Nothing has run in this context, so no abort has been raised into its VM.
|
||||
calogValueNil(&result);
|
||||
calogAbortAll(calog, &result);
|
||||
calogValueFree(&result);
|
||||
|
||||
calogContextEval(ctx, item->source);
|
||||
for (index = 0; index < PUMP_LIMIT; index++) {
|
||||
calogPump(calog);
|
||||
if (atomic_load(&errorCount) > 0) {
|
||||
break;
|
||||
}
|
||||
nanosleep(&tick, NULL);
|
||||
}
|
||||
calogPump(calog);
|
||||
|
||||
if (item->reports) {
|
||||
checkEngine(atomic_load(&errorCount) > 0, item->name, "a script that is broken still reports, mid-abort", __LINE__);
|
||||
} else {
|
||||
checkEngine(atomic_load(&errorCount) == 0, item->name, "engine finds syntax errors only by running, so the abort wins", __LINE__);
|
||||
}
|
||||
|
||||
calogContextClose(ctx);
|
||||
calogDestroy(calog);
|
||||
}
|
||||
|
||||
|
||||
// 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) {
|
||||
|
|
@ -226,11 +296,34 @@ int main(void) {
|
|||
{ &calogTclEngine, "tcl", "mark\nstopAll\nwhile {1} {}" },
|
||||
{ &calogJanetEngine, "janet", "(mark) (stopAll) (var i 0) (while true (set i (+ i 1)))" }
|
||||
};
|
||||
// The same ten engines, each handed something it can only reject.
|
||||
//
|
||||
// Nine of them find the problem with their PARSER, before a single instruction runs, so the
|
||||
// diagnostic survives the abort. my-basic is the exception, and not by choice: mb_load_string
|
||||
// accepts almost anything and the error only appears once the statement RUNS -- by which point
|
||||
// the step hook has already unwound the script, exactly as it must for Ctrl-C to work on a
|
||||
// runaway loop. The two cannot both hold on an engine with no separate parse step, so the
|
||||
// asymmetry is asserted here rather than papered over.
|
||||
static const BrokenCaseT broken[] = {
|
||||
{ &calogLuaEngine, "lua", "if if if", true },
|
||||
{ &calogJsEngine, "js", "function ( { )", true },
|
||||
{ &calogSquirrelEngine, "squirrel", "function ( { )", true },
|
||||
{ &calogMyBasicEngine, "my-basic", "IF IF IF", false },
|
||||
{ &calogBerryEngine, "berry", "def def def", true },
|
||||
{ &calogS7Engine, "s7", "(((", true },
|
||||
{ &calogWrenEngine, "wren", "class class class", true },
|
||||
{ &calogMrubyEngine, "mruby", "def def def", true },
|
||||
{ &calogTclEngine, "tcl", "set x [", true },
|
||||
{ &calogJanetEngine, "janet", "(((", true }
|
||||
};
|
||||
size_t index;
|
||||
|
||||
for (index = 0; index < sizeof(cases) / sizeof(cases[0]); index++) {
|
||||
runCase(&cases[index]);
|
||||
}
|
||||
for (index = 0; index < sizeof(broken) / sizeof(broken[0]); index++) {
|
||||
runBrokenCase(&broken[index]);
|
||||
}
|
||||
testLatchIsPerRuntime();
|
||||
|
||||
printf("testExit: %d checks, %d failed\n", testsRun, testsFailed);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include "calog.h"
|
||||
#include "calogTask.h"
|
||||
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
|
|
@ -34,6 +35,7 @@ static int32_t nativeReport(CalogValueT *args, int32_t argCount, CalogValueT *re
|
|||
static void onError(uint64_t contextId, const char *message, void *userData);
|
||||
static void resetFlags(void);
|
||||
static void runLimited(const CalogEngineT *engine, const char *source, const CalogLimitsT *limits);
|
||||
static void runLimitedSettle(const CalogEngineT *engine, const char *source, const CalogLimitsT *limits);
|
||||
|
||||
|
||||
static void checkImpl(bool condition, const char *message, int32_t line) {
|
||||
|
|
@ -128,8 +130,32 @@ static void runLimited(const CalogEngineT *engine, const char *source, const Cal
|
|||
}
|
||||
|
||||
|
||||
// As runLimited, but for a script that SPAWNS: the interesting behaviour belongs to a child running
|
||||
// on its own thread, so there is nothing for the parent to signal done() about. Pump the full budget
|
||||
// and let the flags say what happened. The parent context is left for calogDestroy to close, since
|
||||
// closing it here would race the child it just started.
|
||||
static void runLimitedSettle(const CalogEngineT *engine, const char *source, const CalogLimitsT *limits) {
|
||||
CalogContextT *ctx;
|
||||
struct timespec ts = { 0, 500000 };
|
||||
int32_t i;
|
||||
|
||||
ctx = calogContextOpenLimited(calog, engine, limits);
|
||||
if (ctx == NULL) {
|
||||
checkImpl(false, "context open failed", __LINE__);
|
||||
return;
|
||||
}
|
||||
calogContextEval(ctx, source);
|
||||
for (i = 0; i < PUMP_LIMIT; i++) {
|
||||
calogPump(calog);
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
calogPump(calog);
|
||||
}
|
||||
|
||||
|
||||
int main(void) {
|
||||
static const char *const allowList[] = { "reached", "report", "done", NULL };
|
||||
static const char *const spawnAllow[] = { "done", "reached", "report", "taskSpawn", NULL };
|
||||
CalogLimitsT allowLimits;
|
||||
CalogLimitsT memLimits;
|
||||
CalogLimitsT timeLimits;
|
||||
|
|
@ -144,6 +170,10 @@ int main(void) {
|
|||
calogRegister(calog, "forbidden", nativeForbidden, NULL);
|
||||
calogRegister(calog, "report", nativeReport, NULL);
|
||||
calogRegisterInline(calog, "done", nativeDone, NULL);
|
||||
if (calogTaskRegister(calog) != calogOkE) {
|
||||
printf("task library registration failed\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 1. Allow-list: the script may call reached/report/done, but forbidden is denied (pcall catches
|
||||
// the "not permitted" error), so its native body never runs.
|
||||
|
|
@ -304,6 +334,54 @@ int main(void) {
|
|||
runLimited(&calogJanetEngine, "(var s \"x\") (while true (set s (string s s)))", &memLimits);
|
||||
CHECK(atomic_load(&errorCount) >= 1, "memory cap (janet): an over-budget script is retired");
|
||||
|
||||
// 11. A sandbox is inherited by the tasks a script spawns. Before this, taskSpawn opened an
|
||||
// UNLIMITED context, so one line of script escaped all three limits at once -- the child could
|
||||
// allocate without bound, run forever, and call natives the parent was denied.
|
||||
{
|
||||
CalogLimitsT spawnLimits;
|
||||
|
||||
// (a) The memory pool is SHARED, not copied: the child draws on the parent's cap, so a child
|
||||
// that tries to blow the budget is retired exactly as the parent would have been. A copied
|
||||
// cap would have let it through, and N children would have multiplied the host's exposure.
|
||||
memset(&spawnLimits, 0, sizeof(spawnLimits));
|
||||
spawnLimits.memoryBytes = 4 * 1024 * 1024;
|
||||
resetFlags();
|
||||
runLimitedSettle(&calogLuaEngine,
|
||||
"taskSpawn('lua', 'local s = \"x\" while true do s = s .. s end')", &spawnLimits);
|
||||
CHECK(atomic_load(&errorCount) >= 1, "spawn: the child is bound by the parent's memory cap");
|
||||
|
||||
// (b) The allow-list is inherited, so a child cannot call what its parent may not. taskSpawn
|
||||
// is itself on the list here -- the point is that permitting the spawn does not permit
|
||||
// everything inside it.
|
||||
memset(&spawnLimits, 0, sizeof(spawnLimits));
|
||||
spawnLimits.allowList = spawnAllow;
|
||||
resetFlags();
|
||||
runLimitedSettle(&calogLuaEngine, "taskSpawn('lua', 'forbidden()')", &spawnLimits);
|
||||
CHECK(!atomic_load(&forbiddenFlag), "spawn: the child inherits the allow-list");
|
||||
|
||||
// (c) The wall-clock deadline is inherited as an ABSOLUTE instant, so a child cannot restart
|
||||
// the budget. The parent's 150 ms is already ticking when the child begins.
|
||||
memset(&spawnLimits, 0, sizeof(spawnLimits));
|
||||
spawnLimits.wallClockMillis = 150;
|
||||
resetFlags();
|
||||
runLimitedSettle(&calogLuaEngine,
|
||||
"taskSpawn('lua', 'local i = 0 while true do i = i + 1 end')", &spawnLimits);
|
||||
CHECK(atomic_load(&errorCount) >= 1, "spawn: the child dies at the parent's deadline");
|
||||
|
||||
// (d) maxContexts bounds the tree. Without it, inheriting the other three still leaves a
|
||||
// spawn loop free to exhaust the host's threads. 2 = the parent plus one child, so the
|
||||
// second spawn is refused rather than quietly granted.
|
||||
memset(&spawnLimits, 0, sizeof(spawnLimits));
|
||||
spawnLimits.allowList = spawnAllow;
|
||||
spawnLimits.maxContexts = 2;
|
||||
resetFlags();
|
||||
runLimitedSettle(&calogLuaEngine,
|
||||
"local a = pcall(taskSpawn, 'lua', 'reached()') "
|
||||
"local b = pcall(taskSpawn, 'lua', 'reached()') "
|
||||
"report(a and not b)", &spawnLimits);
|
||||
CHECK(atomic_load(&reportValue) == 1, "spawn: maxContexts refuses the spawn past the bound");
|
||||
}
|
||||
|
||||
calogDestroy(calog);
|
||||
|
||||
printf("\n%d checks, %d failed\n", testsRun, testsFailed);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ 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 testReclaimUnderConcurrentDrops(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);
|
||||
|
|
@ -63,14 +64,17 @@ static void checkImpl(bool condition, const char *message, int32_t line) {
|
|||
}
|
||||
|
||||
|
||||
// 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.
|
||||
// End THIS script, leaving the runtime and every other script running -- what taskExit does, and the
|
||||
// only way a script ends itself. Inline, so it runs on the calling script's own thread. Deferred:
|
||||
// the chunk finishes, then the context retires and its thread exits, which is the moment that
|
||||
// matters here (an engine handle the script published must not outlive its VM).
|
||||
static int32_t nativeEndSelf(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
|
||||
(void)args;
|
||||
(void)argCount;
|
||||
(void)userData;
|
||||
calogValueNil(result);
|
||||
return calogAbortCurrent(result);
|
||||
calogCurrentRetire();
|
||||
return calogOkE;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -318,6 +322,52 @@ static void testCrossEngineValueOutlivesOwner(void) {
|
|||
}
|
||||
|
||||
|
||||
// The reclaim sweep runs on a dying context's own thread while OTHER threads still hold references
|
||||
// to its callables and can drop them at any moment -- the timer thread retains a callback, invokes
|
||||
// it, and releases. A drop that lands between the sweep taking the list and the sweep taking its own
|
||||
// reference would have it adopt a callable whose finalize was already committed. The sweep now does
|
||||
// both in one critical section with a conditional retain, so that cannot happen; this churns the
|
||||
// path to keep it honest. QuickJS is deliberate again: it aborts if a handle outlives its VM.
|
||||
static void testReclaimUnderConcurrentDrops(void) {
|
||||
struct timespec tick = { 0, PUMP_INTERVAL_NS };
|
||||
int32_t round;
|
||||
int32_t i;
|
||||
|
||||
printf(" reclaim under concurrent drops: 20 rounds of close-while-firing\n");
|
||||
fflush(stdout);
|
||||
for (round = 0; round < 20; round++) {
|
||||
CalogContextT *ctx;
|
||||
|
||||
startRuntime();
|
||||
if (calog == NULL) {
|
||||
return;
|
||||
}
|
||||
ctx = calogContextOpen(calog, &calogJsEngine);
|
||||
if (ctx == NULL) {
|
||||
CHECK(false, "reclaim churn: context open failed");
|
||||
calogDestroy(calog);
|
||||
return;
|
||||
}
|
||||
// A fast repeating timer plus a subscriber: the timer thread is retaining, invoking and
|
||||
// releasing this context's callable continuously while the close below tears it down.
|
||||
calogContextEval(ctx,
|
||||
"timerEvery(1, function () {});"
|
||||
"psSubscribe('t', function () {});"
|
||||
"calogExport('e' + Math.random(), function () {});"
|
||||
"ready();");
|
||||
pumpUntilReady();
|
||||
for (i = 0; i < 8; i++) {
|
||||
calogPump(calog);
|
||||
nanosleep(&tick, NULL);
|
||||
}
|
||||
calogContextClose(ctx); // joins: the reclaim sweep runs here, timer thread still live
|
||||
calogPump(calog);
|
||||
calogDestroy(calog);
|
||||
}
|
||||
CHECK(true, "closing a context while its callables are being dropped elsewhere is clean");
|
||||
}
|
||||
|
||||
|
||||
int main(void) {
|
||||
testHeldCallableSurvivesTeardown("a JS subscriber still registered at teardown",
|
||||
"psSubscribe('t', function () { return 1; }); ready();");
|
||||
|
|
@ -339,6 +389,7 @@ int main(void) {
|
|||
testOwnerDiesBeforeTheRuntime("a JS subscriber whose script ends itself",
|
||||
"psSubscribe('t', function () {}); ready(); endSelf();", false);
|
||||
testCrossEngineValueOutlivesOwner();
|
||||
testReclaimUnderConcurrentDrops();
|
||||
testGuardsAfterShutdown();
|
||||
|
||||
printf("\n%d checks, %d failed\n", testsRun, testsFailed);
|
||||
|
|
|
|||
|
|
@ -58,22 +58,12 @@ skip_no_ossl() { # <name> <target>
|
|||
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
|
||||
if "$OUT/$name-musl" >/dev/null 2>&1; then
|
||||
echo " [musl RUN ok] $name"; pass=$((pass+1))
|
||||
else
|
||||
echo " [musl RAN, nonzero] $name"; pass=$((pass+1)) # some tests exit nonzero by design
|
||||
fi
|
||||
else
|
||||
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
|
||||
# Build a fully static musl binary and RUN it here. A nonzero exit is a FAILURE: every test in this
|
||||
# matrix returns 0 only when all its checks passed. This used to pass them regardless ("RAN, nonzero"
|
||||
# counted as ok, on the theory that some exit nonzero by design -- none do), and it hid a real one:
|
||||
# testEngineMyBasic reported success on musl while 13 of its 20 checks were failing. The last line of
|
||||
# output is echoed so the check counts are visible, and timeout bounds a hang.
|
||||
mrun() { # 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
|
||||
|
|
@ -85,7 +75,6 @@ mrunStrict() { # name, then compiler args -- build static musl, then RUN it; no
|
|||
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
|
||||
|
|
@ -168,7 +157,7 @@ if OSSL=$(ossl_flags musl); then
|
|||
# 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
|
||||
mrun 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
|
||||
|
|
@ -189,11 +178,11 @@ if [ -f "$ROOT/build/cross/musl/tcl/libtcl9.0.a" ]; then
|
|||
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
|
||||
"$OUT/testEngineSquirrel-musl" >/dev/null 2>&1; echo " [musl RUN ok] testEngineSquirrel"; pass=$((pass+1))
|
||||
else echo " [musl FAIL] testEngineSquirrel"; fail=$((fail+1)); fi
|
||||
# Squirrel is C++ (vendored VM); the calog .c files stay C (-x c), the VM is C++ (-x c++). It goes
|
||||
# through the same runner as everything else -- it used to run its binary and DISCARD the exit code,
|
||||
# reporting "RUN ok" unconditionally, which is the same defect as the old lenient mrun but total.
|
||||
mrun testEngineSquirrel "$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
|
||||
|
||||
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"
|
||||
|
|
|
|||
10
vendor/berry/berry_conf.h
vendored
10
vendor/berry/berry_conf.h
vendored
|
|
@ -149,21 +149,21 @@
|
|||
* otherwise disable the feature.
|
||||
* Default: 1
|
||||
**/
|
||||
#define BE_USE_BYTECODE_SAVER 1
|
||||
#define BE_USE_BYTECODE_SAVER 0 /* --- calog patch: no writing .bec to disk --- */
|
||||
|
||||
/* Macro: BE_USE_BYTECODE_LOADER
|
||||
* Enable load bytecode from file when BE_USE_BYTECODE_LOADER is not 0,
|
||||
* otherwise disable the feature.
|
||||
* Default: 1
|
||||
**/
|
||||
#define BE_USE_BYTECODE_LOADER 1
|
||||
#define BE_USE_BYTECODE_LOADER 0 /* --- calog patch: unverified bytecode is a VM escape --- */
|
||||
|
||||
/* Macro: BE_USE_SHARED_LIB
|
||||
* Enable shared library when BE_USE_SHARED_LIB is not 0,
|
||||
* otherwise disable the feature.
|
||||
* Default: 1
|
||||
**/
|
||||
#define BE_USE_SHARED_LIB 1
|
||||
#define BE_USE_SHARED_LIB 0 /* --- calog patch: no dlopen of attacker .so --- */
|
||||
|
||||
/* Macro: BE_USE_OVERLOAD_HASH
|
||||
* Allows instances to overload hash methods for use in the
|
||||
|
|
@ -213,13 +213,13 @@
|
|||
#define BE_USE_JSON_MODULE 1
|
||||
#define BE_USE_MATH_MODULE 1
|
||||
#define BE_USE_TIME_MODULE 1
|
||||
#define BE_USE_OS_MODULE 1
|
||||
#define BE_USE_OS_MODULE 0 /* --- calog patch: os.system/os.exit/chdir bypass every calog policy --- */
|
||||
#define BE_USE_GLOBAL_MODULE 1
|
||||
#define BE_USE_SYS_MODULE 1
|
||||
#define BE_USE_DEBUG_MODULE 1
|
||||
#define BE_USE_GC_MODULE 1
|
||||
#define BE_USE_SOLIDIFY_MODULE 1
|
||||
#define BE_USE_INTROSPECT_MODULE 1
|
||||
#define BE_USE_INTROSPECT_MODULE 0 /* --- calog patch: introspect.fromptr forges object pointers --- */
|
||||
#define BE_USE_STRICT_MODULE 1
|
||||
|
||||
/* Macro: BE_EXPLICIT_XXX
|
||||
|
|
|
|||
6
vendor/berry/src/be_filelib.c
vendored
6
vendor/berry/src/be_filelib.c
vendored
|
|
@ -239,6 +239,12 @@ int be_nfunc_open(bvm *vm)
|
|||
#endif
|
||||
{ NULL, NULL }
|
||||
};
|
||||
/* --- calog patch: a script reaches the host only through calog natives, where the allow-list,
|
||||
* the memory cap and the wall-clock budget can see it. open() is direct filesystem read/write
|
||||
* around all three; calog's fs* natives are the gated equivalent. The function itself stays --
|
||||
* it is named by the precompiled builtin table, which would otherwise need regenerating -- and
|
||||
* simply refuses. be_raise does not return. --- */
|
||||
be_raise(vm, "io_error", "calog: file access is not available to scripts; use the fs* natives");
|
||||
fname = argc >= 1 && be_isstring(vm, 1) ? be_tostring(vm, 1) : NULL;
|
||||
mode = argc >= 2 && be_isstring(vm, 2) ? be_tostring(vm, 2) : "r";
|
||||
if (fname) {
|
||||
|
|
|
|||
8
vendor/ourbasic/ourBasic.c
vendored
8
vendor/ourbasic/ourBasic.c
vendored
|
|
@ -5651,6 +5651,14 @@ static _data_e _get_symbol_type(mb_interpreter_t* s, char* sym, _raw_t* value) {
|
|||
#endif /* MB_ENABLE_MODULE */
|
||||
}
|
||||
/* Import another file */
|
||||
/* [calog fork] A script reaches the host only through calog natives, where the
|
||||
* allow-list, the memory cap and the wall-clock budget can see it. IMPORT "path"
|
||||
* reads an arbitrary file off disk and executes it as BASIC -- filesystem access and
|
||||
* arbitrary code execution in one, around all three. Module imports (IMPORT "@name",
|
||||
* handled above) are in-memory and stay. Scripts compose through taskLoad/calogExport. */
|
||||
_handle_error_now(s, SE_PS_FAILED_TO_OPEN_FILE, s->source_file, MB_FUNC_ERR);
|
||||
|
||||
goto _end_import;
|
||||
buf = _load_file(s, sym + 1, ":", true);
|
||||
if(buf) {
|
||||
if(buf == sym + 1) {
|
||||
|
|
|
|||
33
vendor/s7/s7.c
vendored
33
vendor/s7/s7.c
vendored
|
|
@ -30980,6 +30980,11 @@ static block_t *expand_filename(s7_scheme *sc, const char *name)
|
|||
static s7_pointer open_input_file_1(s7_scheme *sc, const char *name, const char *mode, const char *caller)
|
||||
{
|
||||
FILE *fp;
|
||||
/* --- calog patch: a script reaches the host only through calog natives, where the
|
||||
* allow-list / memory cap / wall-clock budget can see it. s7 has no build switch and no
|
||||
* adapter-side rebinding that survives unlet/#_, so the capability is removed here. --- */
|
||||
file_error_nr(sc, caller, "calog: reading files is not available to scripts; use the fs* natives:", name);
|
||||
|
||||
#if WITH_GCC
|
||||
block_t *b;
|
||||
#endif
|
||||
|
|
@ -31122,6 +31127,11 @@ static const port_functions_t output_file_functions =
|
|||
s7_pointer s7_open_output_file(s7_scheme *sc, const char *name, const char *mode)
|
||||
{
|
||||
FILE *fp;
|
||||
/* --- calog patch: a script reaches the host only through calog natives, where the
|
||||
* allow-list / memory cap / wall-clock budget can see it. s7 has no build switch and no
|
||||
* adapter-side rebinding that survives unlet/#_, so the capability is removed here. --- */
|
||||
file_error_nr(sc, "open-output-file", "calog: writing files is not available to scripts; use the fs* natives:", name);
|
||||
|
||||
s7_pointer port;
|
||||
block_t *block, *b;
|
||||
/* see if we can open this file before allocating a port */
|
||||
|
|
@ -32471,6 +32481,10 @@ static s7_pointer g_load(s7_scheme *sc, s7_pointer args)
|
|||
#define H_load "(load file (let (rootlet))) loads the scheme file 'file'. The 'let' argument \
|
||||
defaults to the rootlet. To load into the current environment instead, pass (curlet)."
|
||||
#define Q_load s7_make_signature(sc, 3, sc->values_symbol, sc->is_string_symbol, has_let_signature(sc))
|
||||
/* --- calog patch: load reads and evaluates an arbitrary path, which is filesystem access and
|
||||
* arbitrary code execution in one. Scripts compose through taskLoad/calogExport instead. --- */
|
||||
return(s7_error(sc, s7_make_symbol(sc, "calog-blocked"),
|
||||
s7_list(sc, 1, s7_make_string(sc, "calog: load is not available to scripts; use fs* + taskLoad"))));
|
||||
|
||||
const s7_pointer name = car(args);
|
||||
const char *fname;
|
||||
|
|
@ -57029,6 +57043,11 @@ static s7_pointer g_emergency_exit(s7_scheme *sc, s7_pointer args)
|
|||
{
|
||||
#define H_emergency_exit "(emergency-exit (obj #t)) exits s7 immediately. 'obj', the value passed to libc's _exit, can be an integer or #t=success (0) or #f=fail (1)."
|
||||
#define Q_emergency_exit s7_make_signature(sc, 2, sc->T, sc->T)
|
||||
/* --- calog patch: a script reaches the host only through calog natives. Ending the run is
|
||||
* calogExit, which latches the abort and lets the host tear down in order; libc exit here
|
||||
* would kill the process mid-run, stranding every other script. --- */
|
||||
return(s7_error(sc, s7_make_symbol(sc, "calog-blocked"),
|
||||
s7_list(sc, 1, s7_make_string(sc, "calog: emergency-exit is not available to scripts; use calogExit"))));
|
||||
|
||||
s7_pointer obj;
|
||||
if (is_null(args)) _exit(EXIT_SUCCESS); /* r7rs spec says use _exit here (it does not call any functions registered with atexit or on_exit) */
|
||||
|
|
@ -57057,6 +57076,11 @@ static s7_pointer g_exit(s7_scheme *sc, s7_pointer args)
|
|||
#define H_exit "(exit obj cobj) exits s7. 'obj', the value passed to libc's exit, can be an integer or #t=success (0) or #f=fail (1). \
|
||||
'cobj' is a boolean (defaults to #f), #t causes exit to call all active c-object gc_free functions."
|
||||
#define Q_exit s7_make_signature(sc, 3, sc->T, sc->T, sc->is_boolean_symbol)
|
||||
/* --- calog patch: a script reaches the host only through calog natives. Ending the run is
|
||||
* calogExit, which latches the abort and lets the host tear down in order; libc exit here
|
||||
* would kill the process mid-run, stranding every other script. --- */
|
||||
return(s7_error(sc, s7_make_symbol(sc, "calog-blocked"),
|
||||
s7_list(sc, 1, s7_make_string(sc, "calog: exit is not available to scripts; use calogExit"))));
|
||||
|
||||
/* calling s7_eval_c_string in an atexit function seems to be problematic -- it works, but args can be changed? */
|
||||
/* r7rs.pdf says exit checks the stack for dynamic-winds and runs the "after" functions, if any,
|
||||
|
|
@ -57101,7 +57125,14 @@ static s7_pointer g_exit(s7_scheme *sc, s7_pointer args)
|
|||
}
|
||||
|
||||
#if WITH_GCC
|
||||
static s7_pointer g_abort(s7_scheme *unused_sc, s7_pointer unused_args) {abort(); return(NULL);}
|
||||
static s7_pointer g_abort(s7_scheme *unused_sc, s7_pointer unused_args)
|
||||
{
|
||||
/* --- calog patch: a script reaches the host only through calog natives, where the
|
||||
* allow-list / memory cap / wall-clock budget can see it. s7 has no build switch and no
|
||||
* adapter-side rebinding that survives unlet/#_, so the capability is removed here. --- */
|
||||
return(s7_error(unused_sc, s7_make_symbol(unused_sc, "calog-blocked"),
|
||||
s7_list(unused_sc, 1, s7_make_string(unused_sc, "calog: abort is not available to scripts"))));
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
|
|
|
|||
24
vendor/squirrel-src/squirrel/sqvm.cpp
vendored
24
vendor/squirrel-src/squirrel/sqvm.cpp
vendored
|
|
@ -704,8 +704,9 @@ extern SQInstructionDesc g_InstrDesc[];
|
|||
// Raise_Error + SQ_THROW so the script unwinds cleanly through exception_trap. The calog helpers are
|
||||
// C-linkage (defined in src/squirrel/squirrelAdapter.c).
|
||||
extern "C" void calogCurrentRetire(void);
|
||||
extern "C" int calogSquirrelTimeExpired(void);
|
||||
extern "C" int calogSquirrelMemExceeded(void);
|
||||
extern "C" int calogSquirrelPoll(void);
|
||||
extern "C" int calogSquirrelMemExceeded(void);
|
||||
extern "C" const SQChar *calogSquirrelStopMessage(int code);
|
||||
thread_local int _calogSqTrip = 0;
|
||||
thread_local SQUnsignedInteger _calogSqPoll = 0;
|
||||
// --- end calog patch ---
|
||||
|
|
@ -758,17 +759,20 @@ exception_restore:
|
|||
if (_calogSqTrip) {
|
||||
SQInteger _calogWhy = _calogSqTrip;
|
||||
_calogSqTrip = 0;
|
||||
// Re-verify memory (a transient spike freed within the same opcode must not kill the
|
||||
// context); a time trip is authoritative.
|
||||
if (_calogWhy == 2 || calogSquirrelMemExceeded()) {
|
||||
calogCurrentRetire();
|
||||
Raise_Error(_calogWhy == 2 ? _SC("context exceeded its time budget")
|
||||
: _SC("context exceeded its memory budget"));
|
||||
// A memory trip is re-verified here (a transient spike freed within the same opcode
|
||||
// must not kill the context); every other reason is authoritative. The adapter has
|
||||
// already done whatever bookkeeping the reason calls for, so all that is left is to
|
||||
// raise the message it hands back and unwind.
|
||||
if (_calogWhy != 1 || calogSquirrelMemExceeded()) {
|
||||
Raise_Error(calogSquirrelStopMessage((int)_calogWhy));
|
||||
SQ_THROW();
|
||||
}
|
||||
}
|
||||
if ((++_calogSqPoll & 1023) == 0 && calogSquirrelTimeExpired()) {
|
||||
_calogSqTrip = 2;
|
||||
if ((++_calogSqPoll & 1023) == 0) {
|
||||
int _calogStop = calogSquirrelPoll();
|
||||
if (_calogStop) {
|
||||
_calogSqTrip = _calogStop;
|
||||
}
|
||||
}
|
||||
// --- end calog patch ---
|
||||
const SQInstruction &_i_ = *ci->_ip++;
|
||||
|
|
|
|||
26
vendor/wren/wren.c
vendored
26
vendor/wren/wren.c
vendored
|
|
@ -3373,14 +3373,16 @@ inline static bool checkArity(WrenVM* vm, Value value, int numArgs)
|
|||
// The main bytecode interpreter loop. This is where the magic happens. It is
|
||||
// also, as you can imagine, highly performance critical.
|
||||
// --- calog patch: per-context sandbox limits (memory cap + wall-clock budget) ---
|
||||
// Wren exposes no allocator hook that can refuse and no interrupt callback, so both limits are
|
||||
// enforced from the bytecode loop. calogWrenLoopCheck (src/wren/wrenAdapter.c) is called once per
|
||||
// loop back-jump (CASE_CODE(LOOP) below) -- the natural throttle point, so a runaway loop and an
|
||||
// exponential doubling are each caught within one iteration -- and returns 1 (memory: vm's own
|
||||
// bytesAllocated over the cap) or 2 (wall-clock deadline passed) or 0. Enforcement uses Wren's own
|
||||
// RUNTIME_ERROR unwind. calogCurrentRetire is the calog core's context-retire request.
|
||||
extern int calogWrenLoopCheck(void* userData, size_t bytesAllocated);
|
||||
extern void calogCurrentRetire(void);
|
||||
// Wren exposes no allocator hook that can refuse and no interrupt callback, so the sandbox limits
|
||||
// AND the runtime abort are enforced from the bytecode loop. calogWrenLoopCheck
|
||||
// (src/wren/wrenAdapter.c) is called once per loop back-jump (CASE_CODE(LOOP) below) -- the natural
|
||||
// throttle point, so a runaway loop and an exponential doubling are each caught within one
|
||||
// iteration -- and returns non-zero when the script must stop. The reason codes stay private to the
|
||||
// adapter: it has already done whatever bookkeeping the reason calls for (retiring the context for a
|
||||
// budget overrun, recording the abort for one that was latched) by the time it returns, and
|
||||
// calogWrenStopMessage turns the code into the message to raise. All this loop does is unwind.
|
||||
extern int calogWrenLoopCheck(void* userData, size_t bytesAllocated);
|
||||
extern const char *calogWrenStopMessage(int code);
|
||||
|
||||
static WrenInterpretResult runInterpreter(WrenVM* vm, register ObjFiber* fiber)
|
||||
{
|
||||
|
|
@ -3874,9 +3876,7 @@ OPCODE(END, 0)
|
|||
int calogStop = calogWrenLoopCheck(vm->config.userData, vm->bytesAllocated);
|
||||
if (calogStop != 0)
|
||||
{
|
||||
const char* calogMsg = calogStop == 2 ? "context exceeded its time budget"
|
||||
: "context exceeded its memory budget";
|
||||
calogCurrentRetire();
|
||||
const char* calogMsg = calogWrenStopMessage(calogStop);
|
||||
vm->fiber->error = wrenNewStringLength(vm, calogMsg, strlen(calogMsg));
|
||||
RUNTIME_ERROR();
|
||||
}
|
||||
|
|
@ -3953,9 +3953,7 @@ OPCODE(END, 0)
|
|||
int calogStop = calogWrenLoopCheck(vm->config.userData, vm->bytesAllocated);
|
||||
if (calogStop != 0)
|
||||
{
|
||||
const char* calogMsg = calogStop == 2 ? "context exceeded its time budget"
|
||||
: "context exceeded its memory budget";
|
||||
calogCurrentRetire();
|
||||
const char* calogMsg = calogWrenStopMessage(calogStop);
|
||||
vm->fiber->error = wrenNewStringLength(vm, calogMsg, strlen(calogMsg));
|
||||
RUNTIME_ERROR();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue