keypad(TRUE) mode not immediately restored after SIGTSTP/SIGCONT
Ferenc Wágner <[email protected]>
| Newsgroups | gmane.comp.lib.ncurses.bugs |
|---|---|
| Message-ID | <[email protected]> |
Hi,
Working with the dialog utility (version 1.3-20250116 in Debian trixie)
I noticed something unexpected. Simple reproduction:
1. Start `dialog --menu Test 15 40 4 1 One 2 Two 3 Three 4 Four`.
2. Press Ctrl-Z to background dialog, then run `fg` at the shell prompt.
3. Press Down to move to the next menu item.
4. Instead of advancing the selection, dialog immediately exits as if
Esc had been pressed.
I worked with an AI agent on the phenomenon and came up with a sensible
bug report for ncurses. I tested the provided minimal reproduction code
and it works as expected. I'm not familiar with the ncurses code base,
but I found the reasoning below plausible, hope you find it helpful. I
cannot judge the suggested fix direction, so take it with a bigger grain
of salt, but the described workaround worked for me. I'm willing to
provide further details if needed.
Regards,
Feri.
------------------------------------------------------------------------
ncurses version: 6.5+20250216 (libncursesw6 6.5+20250216-2, Debian 13 "trixie")
Also traced against the current ncurses/master sources on
https://github.com/mirror/ncurses (a read-only mirror), so this is not
specific to the packaged snapshot.
TERM=xterm (also reproduces with xterm-256color).
Summary
-------
After a curses program using keypad(win, TRUE) is suspended with Ctrl-Z
(SIGTSTP) and resumed with `fg` (SIGCONT), the terminal is left out of
application keypad mode. The *first* special key pressed after resume
(e.g. an arrow key) is misreported: getch() returns a bare KEY_ESCAPE
(27), typically followed immediately by the escape sequence's trailing
bytes read back as ordinary characters, instead of the expected KEY_*
code. Every special key pressed *after* that first one is reported
correctly again -- ncurses silently self-heals, but one keystroke too
late. The screen itself is redrawn correctly on resume, so this is
easy to miss unless you specifically press a special key right after
resuming.
This is user-visible and disruptive in interactive full-screen tools:
in dialog(1)-based menus, for instance, the resulting bare Escape is
interpreted as Cancel, so the very first keypress after resuming a
suspended dialog(1) session unexpectedly cancels/exits the program
instead of moving the menu selection.
Root cause
----------
Traced in the current sources (ncurses/tty/lib_tstp.c,
ncurses/tinfo/lib_options.c, ncurses/base/lib_getch.c):
1. On SIGTSTP, handle_SIGTSTP() (ncurses/tty/lib_tstp.c) calls
endwin() before actually stopping the process, so the terminal is
usable by the shell while the program is suspended.
2. endwin() (ncurses/base/lib_endwin.c) calls reset_shell_mode()
(ncurses/tinfo/lib_ttyflags.c; the "reset_shell_mode" branch of
drv_mode() in ncurses/tinfo/tinfo_driver.c on term-driver builds
does the equivalent), which unconditionally calls
_nc_keypad(sp, FALSE):
if (SP_PARM) {
_nc_keypad(SP_PARM, FALSE);
_nc_flush();
}
This call is necessary and deliberate here: while the program is
suspended the shell owns the terminal, and the shell does not want
function keys delivered in application-keypad encoding, so
`keypad_local` (rmkx) genuinely must be sent every time. The bug is
not that this call happens, but what it happens to clobber as a
side effect (see step 3).
3. _nc_keypad(sp, FALSE) (ncurses/tinfo/lib_options.c) is the single
function used both to physically toggle the terminal's keypad
transmission mode (by sending keypad_xmit/keypad_local) and to
record the last-requested state, as one and the same flag:
if (flag) {
(void) NCURSES_PUTP2_FLUSH("keypad_xmit", keypad_xmit);
} else if (keypad_local) {
(void) NCURSES_PUTP2_FLUSH("keypad_local", keypad_local);
}
...
sp->_keypad_on = flag;
Sending rmkx to the terminal is correct and required (step 2), but
because it goes through the same _nc_keypad() call that also
updates sp->_keypad_on, it has the side effect of clobbering the
one persistent record that reset_prog_mode() (step 4) consults to
decide whether keypad_xmit needs to be re-sent on resume. A
separate, per-window flag recording the application's actual
intent does exist -- win->_use_keypad, set once by
keypad(win, TRUE) and otherwise unchanged by the suspend/resume
cycle (see point 5) -- but reset_prog_mode() cannot consult it: it
runs at the SCREEN level, before any specific window is back in
hand, and a SCREEN may have several windows with independently
different keypad settings, so there is no single per-window value
it could even validly substitute for sp->_keypad_on here. Only
_nc_wgetch()'s self-heal check (step 5) ever compares the two
flags to each other, and by then it is one keystroke too late.
4. After SIGCONT, handle_SIGTSTP()'s resume path calls doupdate(),
whose own comment says: "This relies on the fact that doupdate()
will restore the program-mode tty state, and issue enter_ca_mode
if need be." That happens via reset_prog_mode(), which -- in the
"reset_prog_mode" branch of drv_mode() (tinfo_driver.c), mirrored
in ncurses/tinfo/lib_ttyflags.c -- does:
if (sp->_keypad_on)
_nc_keypad(sp, TRUE);
Since step 3 already cleared `_keypad_on`, this condition is now
false, so `keypad_xmit` (smkx) is not re-sent here.
5. Why only the *first* subsequent key is affected: _nc_wgetch()
(ncurses/base/lib_getch.c) independently self-heals this, but only
as each read is serviced. At the very top of every call, before
anything is read:
if (win->_use_keypad != sp->_keypad_on)
_nc_keypad(sp, win->_use_keypad);
`win->_use_keypad` is still TRUE (set once at startup and never
touched by the suspend/resume path), while `sp->_keypad_on` is
FALSE (cleared in step 3), so the very first getch() call after
resume detects the mismatch and calls _nc_keypad(sp, TRUE), which
re-sends `keypad_xmit` and sets `_keypad_on` back to TRUE. But this
happens only once that first getch() call has already started
servicing the read -- the terminal had already transmitted the
user's pending keypress (typed right after `fg`) in *normal*
(non-application) mode, before this correction reaches it. So that
one keystroke is misparsed as a bare Escape plus literal trailing
bytes, using the stale terminal state; every keystroke after it
arrives once the terminal is correctly back in application mode
and is reported correctly.
Net effect: `_keypad_on` is being used for two different things --
"should the terminal currently be sending function-key escape
sequences" (a physical/shell-mode concern) and "did the application
ask for keypad mode" (a program-mode concern, already tracked
separately per-window as `_use_keypad`) -- and the shell-mode
transition clobbers the flag the program-mode transition and the
per-getch() self-heal both depend on, delaying the correction by
exactly one keystroke.
This matches independently reported instances of the same symptom
that predate this analysis:
- https://stackoverflow.com/questions/1189708 ("ncurses to external
shell and back messing with keys" -- "ncurses thinks ^[ and A are
seen respectively if I press the up arrow twice"; the accepted
answer's workaround is to explicitly call keypad(win, TRUE) again,
which works because keypad() calls _nc_keypad() unconditionally,
bypassing the stale flag)
- https://stackoverflow.com/questions/3328528 (same symptom pattern
after a def_prog_mode()/endwin()/.../reset_prog_mode()/refresh()
round trip)
How to reproduce
-----------------
Minimal, self-contained reproducer attached (repro.c),
using nothing but ncurses' own default SIGTSTP/SIGCONT handling (no
application-installed signal handler):
cc -o repro repro.c -lncursesw
./repro
Steps:
1. Press an arrow key a few times: getch() correctly reports
KEY_UP / KEY_DOWN / KEY_LEFT / KEY_RIGHT.
2. Press Ctrl-Z, then run `fg` in the shell to resume.
3. Press an arrow key once: getch() reports a bare KEY_ESCAPE (27)
instead of the expected KEY_* code (the sequence's trailing
bytes, e.g. 'O' and 'B' for xterm's Down arrow, are read back
immediately afterward as ordinary characters).
4. Press an arrow key again: getch() now correctly reports the
expected KEY_* code again, without any further Ctrl-Z/fg cycle.
Expected: arrow keys are recognized identically before and after a
Ctrl-Z/fg cycle, with no one-keystroke "misfire" window.
Actual: exactly the first special key pressed after resume is
misparsed as a bare Escape plus literal trailing bytes; every key
after that is fine again.
Suggested fix
-------------
sp->_keypad_on is overloaded: reset_shell_mode() must unconditionally
turn the terminal's *physical* keypad mode off for the shell's sake,
but it does so via _nc_keypad(), which also happens to overwrite the
one persistent record (sp->_keypad_on) that reset_prog_mode() later
relies on to know whether keypad mode should be turned back on. A
per-window record of the application's actual intent already exists
(win->_use_keypad), but reset_prog_mode() runs before any window is
back in hand and can't use it directly.
A fix along the following lines would let reset_prog_mode() do the
right thing immediately, instead of leaving it to be discovered one
keystroke late by _nc_wgetch()'s self-heal check: save
sp->_keypad_on's value before reset_shell_mode() clears it (e.g. in
def_prog_mode(), analogous to how it already saves other tty state
across this same round trip via Nttyb), and restore it afterward, so
that reset_prog_mode()'s existing
`if (sp->_keypad_on) _nc_keypad(sp, TRUE);` check sees the correct
pre-suspend value and can re-send keypad_xmit right away, before the
user's next keystroke is even typed.
Workaround in the meantime, for applications that cannot patch
ncurses: install a SIGCONT handler that unconditionally re-sends the
terminfo `smkx` capability (or calls keypad(win, TRUE) again) as soon
as the process resumes, before the user's next keystroke can reach the
terminal in the wrong mode.
Happy to provide more detail, a pty-based automated reproduction
script, or test a candidate patch.
Thank you for maintaining ncurses.
repro.c
(text/x-csrc, 1.8 KB)
/*
* Minimal reproducer for: after suspending an ncurses program with
* Ctrl-Z (SIGTSTP) and resuming it with `fg` (SIGCONT), the terminal is
* left out of application keypad mode, so the next special key (e.g. an
* arrow key) is reported back as a bare KEY_ESCAPE followed by the
* literal trailing bytes of the escape sequence, instead of the
* expected KEY_* code.
*
* Build: cc -o keypad_sigtstp_repro keypad_sigtstp_repro.c -lncursesw
* Run: ./keypad_sigtstp_repro
* Steps: 1. Press an arrow key a few times -- observe correct KEY_UP /
* KEY_DOWN / ... reports.
* 2. Press Ctrl-Z, then run `fg` to resume.
* 3. Press an arrow key again -- it is now reported as
* KEY_ESCAPE (or an unrecognized raw byte sequence),
* instead of the expected KEY_* code.
*
* This relies only on ncurses' own default SIGTSTP handling -- no
* application-level signal handler is installed.
*/
#include <curses.h>
#include <stdlib.h>
int
main(void)
{
initscr();
cbreak();
noecho();
keypad(stdscr, TRUE);
printw("Press arrow keys; Ctrl-Z then `fg` to reproduce; 'q' to quit.\n");
refresh();
for (;;) {
int ch = getch();
if (ch == 'q')
break;
printw("getch() returned %d", ch);
if (ch == KEY_UP)
printw(" (KEY_UP)");
else if (ch == KEY_DOWN)
printw(" (KEY_DOWN)");
else if (ch == KEY_LEFT)
printw(" (KEY_LEFT)");
else if (ch == KEY_RIGHT)
printw(" (KEY_RIGHT)");
else if (ch == 27)
printw(" (bare ESC -- bug reproduced if this follows Ctrl-Z/fg "
"and an arrow key was pressed)");
printw("\n");
refresh();
}
endwin();
return EXIT_SUCCESS;
}