• JS embedding: timer and immediate callbacks crash when capturing local

    From Deucе@1:103/705 to GitLab issue in main/sbbs on Thu Oct 22 10:30:36 2026
    open https://gitlab.synchro.net/main/sbbs/-/work_items/1294

    Callbacks registered through the JS event APIs can crash the host process when the callback closes over a local variable. This reproduces in a standalone JSexec script, without IRCd, SQLite, sockets, background workers, or an explicit garbage collection.

    Confirmed affected APIs: `js.setInterval()`, `js.setTimeout()`, and `js.setImmediate()`.

    ### Minimal reproducer

    Save as `closure-crash.js`:

    ```javascript
    "use strict";
    function Example() {
    var self = this;
    this.interval = js.setInterval(function() {
    print(self.value);
    js.clearInterval(self.interval);
    }, 1);
    this.value = "captured value";
    }
    var example = new Example();
    js.do_callbacks = true;
    ```

    Run with an existing Synchronet configuration:

    ```sh
    jsexec -C -n -c /path/to/ctrl closure-crash.js
    ```

    Expected: print `captured value`, clear the interval, and exit normally.

    Actual: the process terminates with `SIGBUS` on FreeBSD (shell exit status 138; Python subprocess return code -10).

    For `setTimeout`, replace `js.setInterval` with `js.setTimeout`. For `setImmediate`, replace the registration with `js.setImmediate(function() { ... });` (no delay argument). Both also crash. The original interval cleanup statement does not execute before the failure: the first access to captured `self` crashes.

    ### Reproduction environment

    - FreeBSD 15.1-RELEASE-p5, amd64.
    - JSexec v3.22a-FreeBSD, debug build compiled October 20, 2026 with Clang 19.1.7, build revision `5ecf952937` (`clang.freebsd.amd64.exe.sbbs/jsexec`).
    - All three APIs also reproduce with the older April 24, 2026 `clang.freebsd.amd64.exe.portable/jsexec` build.
    - Each failing variant was run separately with core dumps disabled. The explicit-`this` workaround below exits successfully on the October build.

    ### Native backtrace

    Captured with LLDB using the minimal interval reproducer (addresses omitted):

    ```text
    stop reason = signal SIGBUS: object-specific hardware error
    #0 js::Interpret(...) jsinterp.cpp:5314
    #1 js::RunScript(...) jsinterp.cpp:653
    #2 js::Invoke(...) jsinterp.cpp:740
    #3 js::ExternalInvoke(...) jsinterp.cpp:863
    #4 JS_CallFunction(...) jsapi.cpp:5111
    #5 js_handle_events(...) js_internal.cpp:1484
    #6 js_exec(...) jsexec.cpp:1142
    #7 main(...) jsexec.cpp:1629
    ```

    The failing SpiderMonkey operation accesses a flat closure upvar:

    ```cpp
    JSObject *obj = &argv[-2].toObject();
    JS_ASSERT(index < obj->getFunctionPrivate()->script()->bindings.countUpvars()); PUSH_COPY(obj->getFlatClosureUpvar(index)); // crash, jsinterp.cpp:5314
    ```

    ### Suspected embedding issue

    This is a hypothesis from inspecting the embedding, not a verified C++ fix:

    - `js_setTimeout()` and `js_setInterval()` convert the callback with `JS_ValueToFunction()` and retain a `JSFunction*` in `ev->cb`.
    - `js_setImmediate()` similarly retains a `JSFunction*` in `rqe->func`.
    - The event loop invokes those pointers with `JS_CallFunction()` rather than retaining and calling the original function-object value. The explicit callback `this` object is rooted; the actual closure instance is not retained/rooted alongside it.
    - This appears to lose the specific closure instance needed for captured variables. Review both preservation of the original callable value and its GC rooting. Using the original function value with the appropriate call API may be necessary; merely rooting a canonical function object may be insufficient.
    - Audit other event registration/dispatch paths that retain callbacks in the same way, including listeners and socket callbacks. Their behavior has not yet been independently tested here.

    ### Working script workaround

    A top-level callback with an explicit `this` object works:

    ```javascript
    "use strict";
    function tick() {
    print(this.value);
    js.clearInterval(this.interval);
    }
    function Example() {
    this.value = "captured value";
    this.interval = js.setInterval(tick, 1, this);
    }
    var example = new Example();
    js.do_callbacks = true;
    ```

    This prints `captured value` and exits with status 0.

    ### Validation for a fix

    Add regressions for closures capturing constructor/function locals with each of the three APIs, both with and without an explicit callback `this`. Include nested closures, multiple independently captured values, forced GC between registration and invocation, and cancellation/one-shot cleanup. Confirm that callback exceptions remain JavaScript errors rather than process crashes and that callback roots are released on cancellation and shutdown.
    --- SBBSecho 3.38-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)