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)