• Re-derive terminal flags after a mid-session reload of the user record

    From Rob Swindell@1:103/705 to GitLab issue in main/sbbs on Fri Sep 4 18:33:18 2026
    open https://gitlab.synchro.net/main/sbbs/-/issues/1233

    ### Background

    A terminal session's effective terminal type is reconciled exactly once, in `logon()` (`src/sbbs3/logon.cpp:171-177`):

    ```c
    const int manual_term = ANSI | RIP | PETSCII | UTF8; // Note: don't turn off NO_EXASCII flag (issue #923)
    if ((useron.misc & AUTOTERM)
    || ((useron.misc & manual_term) && (useron.misc & manual_term) != (autoterm & manual_term))
    || ((autoterm & UTF8) && !(useron.misc & UTF8))) {
    useron.misc &= ~manual_term;
    useron.misc |= (AUTOTERM | (autoterm & ~NO_EXASCII));
    }
    ```

    That write is to memory only. The user record's terminal bits are therefore a *per-account seed* recording the terminal type last detected for that account by any connection — which is deliberate: with `AUTOTERM` off, the manual prompts in `exec/user_terminal.js:113-118` (and the `useredit`/`uedit` checkboxes) default from those stored bits, so a user who turns auto-detection off isn't asked "ANSI terminal? [no]" on an obviously-ANSI terminal.

    ### Problem

    Nothing re-applies that reconciliation when the record is reloaded over `sbbs->useron` mid-session, and there are two paths that do exactly that:

    1. **`js_user.cpp`** — the end of `js_user_set()` invalidates the cache
    (`if (!user_is_guest(p->user)) p->cached = false;`), and `js_user_get()`
    unconditionally calls `p->getuserdat()`, which `fgetuserdat()`s the record
    into `p->user`. For the global session user object, `p->user` **is**
    `&sbbs->useron` (`js_CreateUserObject(..., global_user=true)`,
    `main.cpp:1540`). So any `user.*` property write followed by any `user.*`
    property read replaces the live user struct with the on-disk record —
    including whatever terminal type another node last seeded there.
    2. **`change_user()`** (`src/sbbs3/str.cpp:1413`) — `;chuser` loads the target
    user's record into `useron` on the sysop's existing terminal, so the session
    inherits that account's saved terminal bits.

    Observed symptom (reported by a sysop, VERT/CVS): log on with a RIP terminal while a second, non-RIP session is online; the non-RIP session begins auto-displaying the `msgscan` menu at the message-reading prompt — a menu `readmsgs.cpp` shows for RIP terminals only.

    That particular symptom is fixed in 86f6584082, which moved the C++ terminal-capability tests off the record bits and onto
    `Terminal::supports()` / `Terminal::charset()` (which re-derive from `sbbs->autoterm` in `Terminal::get_flags()`, `terminal.h:117-133`). What remains are the consumers that read the record's copy directly:

    - `user.compare_ars("RIP")` (also `ANSI`/`PETSCII`/`CP437`/`UTF8`) from JS —
    `js_chk_ar()` calls `p->getuserdat()` and then the library `chk_ar()`
    (`userdat.c:2332`), which tests `user->misc & RIP`. (`js_user.cpp:1577`
    already documents preferring `bbs.compare_ars()` in the terminal server,
    which resolves to the terminal-aware `sbbs_t::chk_ar()`.)
    - Stock JS reading `user.settings & USER_RIP` directly, e.g.
    `exec/load/user_info_prompts.js:228`.
    - `;chuser`, as above.

    There is a second-order effect worth noting: after a reload, the session's `useron.misc` holds another connection's terminal bits, and the next
    whole-word `putusermisc()` (e.g. `user.settings ^= USER_EXPERT` from any of the JS command shells) writes them straight back to the record. A session that never detected RIP can thus re-persist RIP as the account's seed.

    ### Proposal

    Extract the `logon()` block into a member function and call it wherever the record is reloaded into `useron`:

    ```c
    void sbbs_t::fixup_term_flags(); // declared in sbbs.h
    ```

    Call sites:

    - `logon()`, where the block is today.
    - `change_user()` after the successful `getuserdat()`.
    - `user_private_t::getuserdat()` in `js_user.cpp`, but **only** when the object
    owns the live session user (`p->user != &p->storage`) and only in the
    terminal server.

    Plumbing note: `user_private_t::getuserdat()` (`js_user.cpp:39-49`) has no `JSContext*` and no `sbbs_t*`. Either pass the context in
    (`p->getuserdat(cx)`) or apply the fixup in `js_user_get()` right after the call. Either way it needs the same "am I in the terminal server?" test the `USER_PROP_MISC` setter already open-codes at `js_user.cpp:656-670` (global has a `bbs` property that is a `js_bbs_class` instance ⇒ `JS_GetContextPrivate()` is the `sbbs_t*`); that is worth factoring into a small helper used by both.

    ### Explicitly not part of this

    - **Do not call `update_terminal()` from the reload path.** It deletes and
    replaces `sbbs->term` (`terminal.cpp:434`); `Terminal::flags()` warns "We
    have potentially destructed ourselves now… use the new object", and
    `inkey.cpp:79` carries a `// update_terminal(this); causes later crash`
    comment. Fix up `useron.misc` only and let the existing lazy self-heal in
    `Terminal::flags()` (`terminal.cpp:334-340`) swap the object at a safe point. - **Do not stop persisting the `manual_term` bits.** They are the seed
    described above; not writing them would regress `user_terminal.js`'s manual
    prompts and the user-editor checkboxes.
    - **The whole-word `misc` write is a separate issue.** `putusermisc()`
    (`js_user.cpp:654`, `execmisc.cpp:1549`, `listfile.cpp:533`) writes all 32
    bits from a node's private copy, so concurrent sessions clobber each other's
    unrelated flags — e.g. `EXPERT` flipping between two logged-on terminals.
    That wants a field-level read-modify-write and is not addressed here.

    ### Known limitation after the fix

    With one record and multiple connections the seed remains last-connection-wins; the stored terminal type will track whichever node most recently wrote `misc`. That only matters at the moment someone turns `AUTOTERM` off.

    ### Test notes

    A repro that needs no concurrency: `;chuser` into an account whose record has the `RIP` bit set, from a non-RIP terminal, and check `user.compare_ars("RIP")`. The original report needs a RIP terminal and a non-RIP terminal logged on to the same account simultaneously, a `user.*` property write on the non-RIP node (any settings toggle), and then a message scan.

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