• uifc/ciolib (curses): keys buffered by ncurses are invisible to kbhit(

    From Rob Swindell@1:103/705 to GitLab issue in main/sbbs on Mon Aug 10 15:11:14 2026
    open https://gitlab.synchro.net/main/sbbs/-/issues/1223

    ## Symptom

    Running SCFG in curses mode over ssh (inside GNU screen, TERM=screen or TERM=linux), pressing ESC often does nothing until a *second*, unrelated key is pressed. The ESC then takes effect. Once it starts, the lag persists for the rest of the session: every keystroke appears to require the next one to be delivered.

    The same defect affects every ciolib curses-mode program, not just SCFG: `uedit`, `umonitor`, `echocfg`, and the Terminal Server's local console.

    ## Root cause

    ESC is the one key that makes curses **read ahead**. On receiving it, ncurses tries to complete a key sequence, consuming further bytes for up to `ESCDELAY` (uifc sets this to 25 ms in `uifc_init()`). Any byte it swallows during that window but does not return is held in **ncurses' own input queue**.

    `curs_kbhit()` in `src/conio/curs_cio.c` answers "is a key waiting?" with a zero-timeout `select()` on the raw stdin file descriptor:

    ```c
    return(select(fileno(stdin)+1,&rfds,NULL,NULL,&timeout) == 1);
    ```

    That cannot see ncurses' queue. The uifc menu loop only calls `getch()` when kbhit says yes:

    `uifc32.c` `uifc_list()` -> `dyn_kbwait()` -> `uifc_kbwait()` -> `kbwait(100)`
    `ciolib_kbhit()` -> `curs_kbhit()`

    so a byte sitting in the ncurses queue is never fetched until *new* bytes arrive on the descriptor. The application falls exactly one keystroke behind and stays behind.

    This is why it shows up over ssh and inside screen: the network coalesces two quick keystrokes into one burst, so a byte routinely lands within 25 ms behind an ESC. Typing ESC quickly followed by anything else is enough.

    ## Reproduction

    Feed a pty ESC immediately followed by another key, in a single write. A probe using the exact curses initialization from `curs_initciolib()` (newterm, cbreak, noecho, nonl, keypad, halfdelay(1), raw, timeout(10), ESCDELAY=25) and the exact `curs_kbhit()` / `ciolib_kbwait()` polling logic:

    | input | result |
    | --- | --- |
    | lone ESC | delivered after 25 ms, correct |
    | ESC + 'x' in one write | ESC delivered; **'x' invisible to select() for a full second**, surfacing only when an unrelated key hit the descriptor |
    | ESC then 'x' at 10 ms (inside ESCDELAY) | same stall |
    | ESC then 'x' at 60 ms (outside ESCDELAY) | no stall (control) |

    Against real SCFG driven over a pty: from a sub-menu, write the two bytes
    `ESC ESC` in a single write. Current behavior backs out one level only and the second ESC disappears. Expected: back out one level and raise the "Exit SCFG" prompt.

    ## Suggested fix

    Have `curs_kbhit()` ask curses as well, and push back whatever it hands over, so the answer accounts for both the descriptor and the curses queue:

    ```c
    if(select(fileno(stdin)+1,&rfds,NULL,NULL,&timeout) == 1)
    return(1);

    /* Completing an escape sequence makes curses read ahead, so bytes it
    * consumed but did not return are held in its own input queue, where
    * the select() above cannot see them. Ask curses too, and push back
    * whatever it hands over. */
    if(suspended)
    return(0);
    nodelay(stdscr, TRUE);
    rc=get_wch(&ch);
    wtimeout(stdscr, 10);
    if(rc == KEY_CODE_YES)
    return(ungetch((int)ch) == OK);
    if(rc == OK)
    return(unget_wch(ch) == OK);

    return(0);
    ```

    The `suspended` guard matters because `curs_kbhit()`, unlike `curs_getch()`, never calls `curs_resume()`, so it can be reached after `endwin()`.

    Validated by building SCFG with and without the change at the same commit and comparing rendered screens (via a terminal emulator) over a pty, with a pristine `ctrl/` copy per run:

    - arrow keys only (which are themselves ESC sequences): 27/27 screens identical - single well-spaced ESC, dialog, arrow, CR: 9/9 screens identical
    - `ESC ESC` in one write: differs, and the difference is the "Exit SCFG" prompt
    correctly appearing

    Two things worth flagging for anyone re-testing this. Comparing raw output bytes rather than rendered screens is misleading: `wgetch()` flushes pending output, so changing the poll cadence changes how redraws are batched while the resulting screen is identical. And blind key sequences in SCFG wander into text-entry fields, after which ESC-prefixed bytes are echoed literally and the comparison stops meaning anything.

    The `ansi_cio.c` driver is not affected; it uses a separate input thread and semaphores rather than polling the descriptor.

    -- *Authored by Claude (Claude Code), on behalf of @rswindell*
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Deucе@1:103/705 to GitLab note in main/sbbs on Mon Aug 10 20:11:27 2026
    https://gitlab.synchro.net/main/sbbs/-/issues/1223#note_10149

    Please read ECMA 35 and the ncurses documentation and sources. Per ECMA 35, ESC ESC is a prohibited sequence. The suggested "fix" appears that it would just make things worse. Trying to align ESCDELAY with wtimeout() is a suckers bet.

    This reads like you don't fully understand the problem and randomly played with whatever knobs you could find until something worked on your tightly controlled setup.
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Rob Swindell@1:103/705 to GitLab note in main/sbbs on Mon Aug 10 22:03:36 2026
    https://gitlab.synchro.net/main/sbbs/-/issues/1223#note_10155

    Correction: the root cause I posted above is wrong, and the fix that was committed for it does not fix anything. It has been reverted.

    Deuce was right to push back on the ESC ESC framing. That repro was a distraction, and the ncurses read-ahead theory does not explain the reported symptom. Here is what the symptom actually is, with the evidence.

    ## The ESC never reaches the descriptor

    strace of the real scfg running inside GNU screen. ESC was written to the terminal at 21:55:02.107. The process polled fd 0 every ~1 ms and got
    Timeout continuously for three seconds:

    ```
    21:55:02.100049 pselect6(1, [0], NULL, NULL, {tv_sec=0, tv_nsec=0}, NULL) = 0 (Timeout)
    ... ~3000 more of the same ...
    21:55:05.110272 read(0, "\33", 1) = 1 <-- only when the NEXT key arrived 21:55:05.110316 read(0, "\33", 1) = 1
    21:55:05.112085 read(0, "[", 1) = 1
    21:55:05.112122 read(0, "B", 1) = 1
    ```

    The second key (a Down-arrow) was sent at 21:55:05.109. Both the stranded ESC and the arrow's own bytes surfaced together at that moment. So the ESC byte
    was not sitting in an ncurses queue - it was not on the file descriptor at
    all. Nothing curses-side could have retrieved it.

    ## It is GNU screen, and the trigger is mouse reporting

    A raw stdin reader, no curses linked in at all, timestamping every read():

    | DECSET modes enabled | lone ESC |
    | --- | --- |
    | none | delivered after 300 ms (screen's maptimeout) |
    | 1000 | HELD |
    | 1002 | HELD |
    | 1003 | HELD |
    | 1006 | delivered after 300 ms |
    | 1000,1006 | HELD |
    | 1003,1006 | HELD |

    So the moment the application turns on X10/normal mouse reporting, screen
    stops applying its maptimeout to a trailing ESC and holds it until the next input byte arrives. The application then runs exactly one keystroke behind, permanently - which is the reported symptom.

    The hold is unbounded, not a long timeout. Waiting 10 seconds after a lone
    ESC, the inner process had read nothing; the next keypress delivered
    `b'\x1bq'` in one read.

    tmux does not do this: with 1000 enabled, ESC is delivered immediately.

    A bare ncurses probe using the exact init sequence from curs_initciolib() (newterm, cbreak, noecho, nonl, keypad, halfdelay(1), raw, timeout(10), ESCDELAY=25) handles a lone ESC correctly inside screen. Adding mousemask()
    to that same probe, and changing nothing else, makes it go one key behind and stay there.

    ## Why it hits us

    curs_initciolib() calls mousemask(), so every ciolib curses-mode program
    picks this up - scfg, uedit, echocfg, umonitor, and the terminal server's
    local console - and only when run inside GNU screen. Outside screen there is
    no lag, which is why this took so long to characterize.

    Reproducing it needs nothing from Synchronet:

    ```
    screen -S t python3 -c '
    import os,sys,time,tty,termios
    fd=sys.stdin.fileno(); termios.tcgetattr(fd); tty.setraw(fd)
    os.write(1, b"\x1b[?1000h") # comment this out and ESC arrives t0=time.time()
    while True:
    d=os.read(fd,4096)
    open("/tmp/rawin.log","a").write("%8.3f %r\n"%(time.time()-t0,d))
    if b"q" in d: break
    '
    ```

    Press ESC, wait, then press another key, and look at /tmp/rawin.log. With
    1000 enabled the ESC is timestamped with the second key, not on its own.

    I have not formed a view on what we should do about it, since the defect is
    in screen rather than here. Options seem to be to stop enabling mouse
    reporting when running under screen (STY is set for its children), or to
    treat it as a screen bug and leave it, or both.

    -- *Authored by Claude (Claude Code), on behalf of @rswindell*
    --- SBBSecho 3.37-Linux
    * Origin: Vertrauen - [vert/cvs/bbs].synchro.net (1:103/705)
  • From Rob Swindell@1:103/705 to GitLab note in main/sbbs on Mon Aug 10 23:04:43 2026
    https://gitlab.synchro.net/main/sbbs/-/issues/1223#note_10159

    Numbers for the mouse-reporting angle, plus a candidate patch. Posting this
    as data rather than as a proposal over the top of e4d2b183bb -- that commit fixes the curses-side hole it describes, and this is the other half.

    ## A/B, both built from e4d2b183bb, same flags, same machine

    SCFG driven over a pty inside GNU screen, pristine ctrl/ copied per run, comparing rendered screens through a terminal emulator rather than raw bytes. Measurement is: press ESC once from a sub-menu, how long until the rendered screen changes.

    | build | lone ESC, 4 trials |
    | --- | --- |
    | e4d2b183bb as-is | no change in 3.0s on every clean trial; takes effect only when the next key arrives |
    | same + mouse gated | 0.326s, 0.328s, 0.328s, 0.327s |

    Outside screen the gated build is unchanged: 0.027s, mouse still enabled.

    The 326 ms is screen's own 300 ms maptimeout plus ESCDELAY. That is the floor for any application taking a bare ESC through screen -- vi pays it too -- so this makes SCFG as responsive as screen permits, not instant.

    ## Patch

    ```diff
    @@ -904,7 +904,11 @@ int curs_initciolib(int inmode)
    }
    mode = inmode;
    #ifdef NCURSES_VERSION_MAJOR
    - {
    + /* GNU screen holds a trailing ESC until the next byte arrives once mouse
    + * reporting is enabled, leaving us a keystroke behind forever (#1223). + * STY is also inherited by a tmux started from within screen, so it only
    + * means "under screen" when TMUX is unset. */
    + if (getenv("STY") == NULL || getenv("TMUX") != NULL) {
    mmask_t msk = BUTTON1_PRESSED|BUTTON1_RELEASED|BUTTON2_PRESSED|...
    #ifdef BUTTON5_PRESSED
    msk |= BUTTON4_PRESSED|BUTTON5_PRESSED;
    ```

    The TMUX half is not defensive coding. Measured: a tmux started from inside a screen session inherits STY (`STY='2161380.sty' TMUX='/tmp/tmux-1000/default,...'`),
    so testing STY alone would disable the mouse under tmux, which does not have the bug.

    ## The cost, which is the part worth arguing about

    No mouse in scfg, uedit, echocfg, umonitor or the local console when they are run inside GNU screen. That is the whole trade: ESC responds, mouse goes away, and only under screen.

    Alternatives, in case either is preferred:

    - leave it, treat it as a screen defect and report it upstream -- the bug is
    genuinely theirs, and this patch is a workaround for someone else's parser
    - make it a knob rather than automatic, so a sysop who wants the mouse under
    screen can keep it

    I have no stake in which. The measurements are the contribution; the policy call is yours.

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