• xp_getch() ignores EOF and returns an uninitialized byte, spinning smb

    From Rob Swindell@1:103/705 to GitLab issue in main/sbbs on Fri Sep 25 13:30:59 2026
    open https://gitlab.synchro.net/main/sbbs/-/work_items/1256

    ## Repro

    ```
    smbutil r1 /path/to/sub.shd < /dev/null
    ```

    `smbutil` prints the first message, then spins at full CPU re-printing its prompt without ever blocking or exiting:

    ```
    Reading /sbbs/data/subs/aiplayground (?=Menu):
    #3 (3)
    Subj :
    ...
    Reading /sbbs/data/subs/aiplayground (?=Menu):
    #3 (3)
    ...
    ```

    Observed over 64 MB of output in roughly 45 seconds before the capture was
    cut off. The same happens for any redirected/closed stdin, e.g. running `smbutil r` from a script, a cron job, or any harness without a tty.

    ## Root cause

    Not in smbutil. `xp_getch()` (src/xpdev/conwrap.c:135) checks only for a read *error* and ignores EOF:

    ```c
    int xp_getch(void)
    {
    char c;

    if (!beensetup)
    xp_termios_setup();

    /* get a char out of stdin */
    if (read(STDIN_FILENO, &c, 1) == -1)
    return 0;

    return c;
    }
    ```

    `read()` returns **0** at EOF, not -1. That path falls through to
    `return c;` with `c` never written, so the function returns an
    **uninitialized stack byte**. Two defects in three lines:

    1. EOF is indistinguishable from a keypress, and there is no value a caller
    can test for it.
    2. The returned value on EOF is indeterminate. `-Wall` does not flag it here
    because the compiler cannot see that the `read()` result constrains
    whether `c` was written.

    `0` is also already the documented-by-implementation return for a read
    error, so it is overloaded before EOF is even considered.

    The consequence at src/sbbs3/smbutil.c:1886 is that
    `switch (toupper(xp_getch()))` receives garbage, hits `default`, and loops forever.

    ## Scope

    sbbscon is **not** affected: it checks `isatty()` (sbbscon.c:1937, :1951) and gates its read behind `xp_kbhit()` (sbbscon.c:1967), so it never reaches `xp_getch()` on a dead stdin.

    The exposed callers are the utilities that call `xp_getch()` unguarded:

    * src/sbbs3/smbutil.c:1886 (the read loop above)
    * src/sbbs3/chksmb.c:227, :1265
    * src/sbbs3/sbbsecho.c:3335

    Only smbutil's is inside a loop, so only it spins; the others return a
    garbage byte once and act on it, which in a pause-for-keypress context is harmless but is still an indeterminate read.

    ## Suggested fix

    Two parts, and the second is a contract question rather than a bug:

    1. In `xp_getch()`, initialize `c` and treat `read() <= 0` as the failure
    path. That removes the indeterminate read regardless of anything else.

    2. Give callers a way to see EOF. `xp_getch()` is DLLEXPORTed from xpdev and
    called from several programs, so changing its return convention is not
    free. Options, in rough order of blast radius:
    * leave the return as `0` for both error and EOF, and fix the interactive
    loops (smbutil, chksmb, fixsmb) to break out on `0` rather than looping
    on `default`;
    * return `EOF` (-1) on end-of-input, distinguishing it from a read error,
    and audit the call sites;
    * add a separate predicate so callers can ask, leaving `xp_getch()`
    untouched.

    The first fixes the spin without touching the exported convention.

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