• Terminal JS: disconnect abort cannot fire while a script is blocked in

    From Rob Swindell@1:103/705 to GitLab issue in main/sbbs on Mon Jul 20 21:03:39 2026
    open https://gitlab.synchro.net/main/sbbs/-/issues/1190

    ## Summary

    A terminal node ran for ~5 hours after its user disconnected, and could not be booted; the sysop had to restart the BBS. The user was in the `wordem` JS door (`xtrn/wordem/wordem.js`).

    The `terminate_on_disconnect` abort (added in `fc82a6d911`) was present in the affected build and did not fire. The reason is structural: the abort threshold is counted in *operation callbacks*, and the operation callback is only reached at JS interpreter branch points. A script parked inside a blocking native (C) call never returns to the interpreter, so the abort can never fire — and for a
    script that is merely I/O-heavy, the abort is delayed by an unbounded amount of wall-clock time.

    Reported by Keyop on IRC `#synchronet`, 2026-07-20.

    ## Affected build

    `master/55ab45c9a7` (Jul 19 2026 13:15), Synchronet 3.22, AlmaLinux 8.10,
    GCC 8.5.0. `fc82a6d911` (2026-06-13) is an ancestor of that commit, so the disconnect-abort mechanism was compiled in — this is not a stale-build report.

    ## Evidence

    Sysop's node log (user redacted):

    ```
    09:07:46 term Node 3 <X> Executing external: ?wordem.js
    09:07:46 term Node 3 <X> new display initialized: 80x24 at 1,1
    09:07:46 term Node 3 <X> JSON client initialized (v1.30)
    09:07:55 term Node 3 SSH dbg 'Received disconnect message: disconnected by user' (-41) popping data from input_thread
    09:07:55 term Node 3 input thread terminated (received 3599 bytes in 1167 blocks)
    09:07:55 term Node 3 output thread terminated (sent 2506172 bytes in 6599 blocks, 379 average, 0 short)
    09:07:55 term Node 3 passthru thread terminated
    09:08:07 term Node 3 <X> JSON client initialized (v1.30)
    <nothing further until sbbs was restarted hours later>
    ```

    For contrast, a working case on another host, same mechanism:

    ```
    15:37:32 term Node 11 <DM> !JavaScript warning /sbbs/exec/load/frame.js line 1719: Disconnected
    15:37:32 term Node 11 <DM> Executing post external program execution module: postxtrn wordem
    ```

    ## Analysis

    **1. `online` was already false when the node kept working.**
    `input_thread` sets `sbbs->online = false` (`src/sbbs3/main.cpp:2460`) immediately *before* logging "input thread terminated". So the flag was clear at 09:07:55 — yet the node logged a second `JSON client initialized` twelve seconds later, then went silent for hours with no `Disconnected` warning and no JS error.

    **2. The abort threshold is a callback count, not a clock.**

    - `src/sbbs3/main.cpp:1443` — terminal server sets
    `js_callback.terminate_on_disconnect = true`
    - `src/sbbs3/exec.cpp:520` — abort once the operation callback has observed
    `!sbbs->online` 10 times (`JS_DISCONNECT_TERMINATE_COUNT`,
    `src/sbbs3/sbbs.h:382`)
    - `src/sbbs3/js_rtpool.cpp:9-23` — a global thread arms the callback via
    `JS_TriggerAllOperationCallbacks()` every 100 ms

    `offline_counter` is incremented only from inside the operation callback and is never reset. Because the callback is armed on a 100 ms tick and *consumed* only when the interpreter next reaches a branch point:

    - A script actively running bytecode dies ~1 second after disconnect (the
    working case above).
    - A script that spends most of its time inside blocking native calls advances
    the counter only during the brief moments it is back in the interpreter —
    which explains the 12 seconds of continued execution here.
    - A script parked inside a native call that never returns advances the counter
    **never**, and cannot be aborted at all.

    **3. Where it parked.**
    The 09:08:07 line is `exec/load/json-client.js:445`, logged *after* `connect()` returns — i.e. the second `JSONClient` constructed by `Game()` (`xtrn/wordem/wordem.js:37`). The next JSON operation is a lock-taking one: `wordem.js:448` `keys("wordem","games",1)` (new game) or `wordem.js:474` `read(...,1)` (existing game).

    Both call `JSONClient.wait()`, which is bounded by `SOCK_TIMEOUT` (30 s) and then **throws** `"timed out waiting for server response"`. No such error was logged, so the script never got back to that loop. The native calls reachable in that path are:

    | call | bound |
    |---|---|
    | `Socket.is_connected` / `data_waiting` | `select()`, effectively instant |
    | `Socket.recvline(131072, 30)` | 30 s (`js_recvline` / `js_sock_read_check`) | | `Socket.send()` via `sendJSON` | **none** — and `exec/load/json-sock.js:21` explicitly sets `nonblocking = false` first |

    `js_socket_sendsocket()` has no timeout at all, so a `send()` to a JSON-db server that has stopped draining its receive buffer parks in the kernel indefinitely, invisible to the operation callback. That is the leading hypothesis for the specific hang; the engine-level defect in (2) holds regardless of which native call it was.

    ## Contributing bugs in `xtrn/wordem/wordem.js`

    These are not the root cause but they set it up, and are worth fixing:

    - `wordem.js:509,511` — `doGame()` takes read locks via `keys(...,1)` /
    `read(...,1)` and never unlocks them; every session also opens a *second*
    JSON connection (global `client` plus `Game.client`) contending on the same
    records. Leaked locks are a plausible reason the JSON-db service stops
    responding.
    - `wordem.js:514` — `id = k` stores the array index instead of `keys[k]`.
    - `wordem.js:98,127` — `console.getkey()` loops with no timeout and no
    `bbs.online` / `console.aborted` check.
    - `wordem.js:522` — the main menu loop exits only on ESC; no online or
    inactivity check.
    - `wordem.js:81` — `drawTile()`'s rejection loop dereferences
    `this.shared.tiles[letters[r]]`, which throws if `_` is absent from the pool,
    and spins hard as the bag empties.

    ## Suggested fixes

    Engine-side (the meaningful one — a sysop cannot audit every third-party JS door):

    1. Make the disconnect abort **time-based** rather than
    operation-callback-count-based, so an I/O-bound script is aborted on a
    predictable schedule.
    2. Teach the blocking native waits to bail when the owning node has gone
    offline: `js_sock_read_check()`, `js_socket_sendsocket()` (which currently
    has no timeout whatsoever), and `console.getkey()`.

    Script-side: add online/aborted checks to the `wordem` input loops and release the locks it takes.

    ## What would confirm the blocked-`send()` hypothesis

    Next time a node wedges, before restarting: `gdb -p <sbbs-pid> -batch -ex 'thread apply all bt'`, or simply `ss -tnp` — a non-empty `Send-Q` on the node's connection to the JSON-db port would settle it.

    ## Related

    - `fc82a6d911` — services/web/terminal: decouple JS client-disconnect
    termination from `auto_terminate`
    - #1188 — `js_recvline()`: mid-line timeout returns a truncated line as if
    complete (adjacent area, different bug)

    — *Authored by Claude (Claude Code), on behalf of @rswindell*
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)