INPUT$ read the keyboard cooked, and buffered it out of everyone's reach¶
Created: 2026-08-05
Status: Fixed - src/basic_builtins.py, BuiltinFunctions.INPUT
Regression test: tests/regression/interpreter/test_input_dollar_posix.py
The fourth and last of the stdin readers, deferred from EDIT_MODE_TYPEAHEAD.md because it changes how a BASIC statement reads its input. The other three are covered by CLI_INPUT_HANDLING_FIXES.md and WINDOWS_CONSOLE_KEYS.md.
The whole keyboard path was:
result = ""
for i in range(num):
char = sys.stdin.read(1)
if not char:
break
result += char
return result
No terminal handling of any kind. Measured under a pty, the terminal was in
full canonical mode while INPUT$ waited - lflag = ICANON|ECHO|ISIG|IEXTEN -
which made it wrong four ways at once.
It waited for Enter. Typing A at INPUT$(1) produced nothing; typing
ABC at INPUT$(3) produced nothing. Both returned only once Enter was
pressed.
It echoed. The terminal echoed the keystroke, so a program that prints the
key itself printed it twice - basic/games/hangman.bas:260 is
L$=INPUT$(1):PRINT X5$L$.
It stranded the rest of what was typed. sys.stdin.read(1) goes through a
TextIOWrapper, which pulls the whole canonical line into a userspace buffer
that nothing else can see. On a tty neither the REPL nor the INPUT statement
can reach it - both read at the file descriptor through readline - so the
keystroke looked lost. It was not lost. The next INPUT$ got it:
RUN <- typed "AB" then Enter
GOT65
RUN <- nothing typed at all
GOT66
RUN <- nothing typed at all
GOT10
Stale keystrokes crossed program runs. On piped input, where there is no
readline, the leftovers came back as REPL commands instead:
?Parse error at line 1, column 3: Unknown statement or command: 'b'.
Ctrl+C could neither reach the program nor stop it. ISIG turned it into a
SIGINT, and Interpreter._setup_break_handler only sets break_requested and
returns, so PEP 475 restarted the blocked read. The break was deferred until
some later keystroke ended the read - and that keystroke was then swallowed by
the break instead of being delivered:
^C <- nothing happens
Q <- meant for the program
Break in PC(30.0) <- 'Q' never reached line 30
What real MBASIC does¶
From the BASIC-80 5.21 reference manual, section 3.17 (recoverable at
git show cfa65e76^:doc/external/basic_ref.txt):
Returns a string of X characters, read from the terminal or from file
number Y. If the terminal is used for input, no characters will be echoed
and all control characters are passed through except Control-C, which is
used to interrupt the execution of the INPUT$ function.
Checked against the real 5.21 binary under cpmemu, driven over a pty so the
echo question could be settled properly (cpmemu puts the tty in raw mode
itself, so any echo seen would have to come from MBASIC):
- No echo. Program lines typed at
Okwere echoed back in the same session, so echo would have been visible had MBASIC produced it. - No Enter. The two bytes
ABwith no newline satisfied two successiveINPUT$(1)calls. - Ctrl+C aborts the program at that line and returns to
Ok. The program does not receiveCHR$(3).CONTis accepted afterwards and re-enters theINPUT$. - Ctrl+C is the only exception:
CHR$(1),CHR$(13)andCHR$(26)all reach the program unchanged.
The fix¶
The established idiom, with one difference: raw mode is entered once for the whole n-character read rather than per character. Dropping back to cooked mode between characters would echo the rest of what was typed and wait for Enter before delivering it.
tty.setraw(fd, termios.TCSANOW)
while len(chars) < num:
if self._take_break_request():
self._raise_break()
if not select.select([fd], [], [], self._BREAK_POLL)[0]:
continue
data = os.read(fd, 1)
if not data:
break
char = data.decode('latin-1')
if char == self._BREAK_CHAR:
self._raise_break()
chars += char
TCSANOW, not setraw()'s TCSAFLUSH default, for the reason it matters
everywhere else here: FLUSH discards input that has arrived but has not been
read. Keys typed ahead of the statement were delivered before this change and
still are - there is a test for exactly that.
os.read and latin-1 for byte transparency, so ASC() means the same thing
under INPUT$, under INKEY$ and on Windows. Enter now arrives as CHR$(13)
rather than CHR$(10), because raw mode turns off ICRNL - which is what a
CP/M console sends.
One byte per os.read, and the break checked as each byte arrives. Reading the
whole remainder at once would swallow anything typed after a Ctrl+C in the
same chunk, and the break would then destroy keystrokes that should have stayed
queued for whoever reads next. Checking the finished string instead of each
byte was worse still - see "What the review caught".
The select() poll is there for the Ctrl+C that arrives before raw mode
does. In that window ISIG is still set, so it becomes a SIGINT, and
_setup_break_handler only sets break_requested and returns - PEP 475 then
restarts the read underneath it. Without the poll the read stays blocked and
the next key typed is consumed into the variable and thrown away by a break
reported against the following line, which is exactly the behavior this fix
exists to remove.
TERMINAL_ERRORS around tcgetattr selects the fallback: piped input, a file,
or a stdin replacement with no real fileno() has no terminal to put in raw
mode, and is read with sys.stdin.read(1) as before. That is deliberate rather
than lazy - with no tty there is no readline either, so the REPL and the
INPUT statement are going through that same TextIOWrapper, and matching
them is what keeps the characters in order. The fallback after a failed raw
read only re-reads if nothing was read yet, or a partial read would deliver its
characters twice.
Windows checks isatty() first and then loops win_read_key(blocking=True),
where "" means "there is no console at all" rather than "no key" - see
src/win_console.py. The isatty() test is what POSIX gets for free from
tcgetattr failing on a pipe: msvcrt.getch() reads CONIN$, the physical
console, not stdin, so without it mbasic prog.bas < in.txt would ignore the
redirection completely and block on a keypress that is never coming.
A Windows Ctrl+C never arrives as a byte - the console turns it into a
CTRL_C_EVENT and CPython raises KeyboardInterrupt out of getch(), which
being a BaseException would sail past the tick loop and abandon the program
with no message. It is caught and turned into the same break.
Ctrl+C¶
Raw mode clears ISIG, so the 0x03 byte is delivered to the reader instead of
becoming a SIGINT. That makes the policy this interpreter's to choose for the
first time, and it now breaks, as the manual describes and as the real binary
does: BreakException, "Break in nn", CONT re-enters the INPUT$.
The alternative - handing CHR$(3) to the program, which is what
docs/help/common/language/functions/input_dollar.md used to claim already
happened - was rejected because raw mode is held for the whole blocking wait.
A program sitting in INPUT$ would have become impossible to interrupt from
the keyboard at all.
Every console read, typed or piped. Real 5.21 under cpmemu aborts on a piped
0x03 exactly as it does on a typed one - piped input is the only console it has
- and matching that is a deliberate call, taken knowing the cost: INPUT$(n)
cannot read a 0x03 out of redirected input, so a program that wants binary
bytes from stdin cannot have that one. INPUT$(n,#f) from a file is exempt and
is where binary data belongs.
What follows the 0x03 survives either way. The check is per character on both paths, so the rest of a piped script stays in the buffer and reaches the REPL rather than being consumed by the read that is being abandoned.
Two deliberate differences from 5.21, noted in the help page:
- 5.21 returns silently to
Ok; this printsBreak in nn, matching what STOP and Ctrl+C duringINPUTalready print here. INKEY$is left alone. It only enters raw mode onceselect()has already reported a key, so on POSIX the terminal is cooked while a program polls it and Ctrl+C usually becomes a SIGINT beforeINKEY$can see the byte - measured:Break in 10, notCHR$(3). It also never blocks, so it cannot trap the user the way a pendingINPUT$would.
BreakException had never actually been raised anywhere in src/ - it was
defined and caught, and that was all. Raising it exposed two things in the
handler that had never run:
- The message was
f"Break in {pc}", which prints the debugging repr:Break in PC(30.0). Both break sites now usepc.line_num, asexecute_stop()already did. - Immediate mode (
PRINT INPUT$(1)at theOkprompt) had no arm for it, so the break would have been reported as?BreakException. Both immediate-mode paths -InteractiveMode.execute_immediatefor the CLI andImmediateExecutor.executefor the visual UIs - now reportBreak, the wayexecute_stop()reports a STOP with no line number.
What the review caught¶
An adversarial review of the first commit (8bf32024) found six real defects, each reproduced against both trees; they are fixed in the follow-up. Worth recording, because most of them are the cure misbehaving rather than the original bug:
- Ctrl+C did nothing to
INPUT$(3)until two more keys were typed. The break was checked on the completed string, so the 0x03 sat in the buffer while the read kept blocking - and raw mode had already taken ISIG away, so no SIGINT could fire either. Strictly worse than the cooked read it replaced. Now checked per byte, as it is read. - A key typed after the Ctrl+C was destroyed with it, because one
os.readcould returnA\x03Band the break discarded the lot. Now one byte per read, so anything after the 0x03 stays in the tty queue. - A SIGINT arriving before raw mode still hung the read and still swallowed whatever key ended it - the original bug, in the window the fix did not cover. Now polled, see above.
- A 0x03 byte from a pipe raised a break where the pre-fix code returned
CHR$(3), and the review called it a regression. It was reverted to data and then deliberately put back: real 5.21 breaks there too, and fidelity won. The reviewers were split on this one for the same reason - the mechanism is real either way, and only the intent settles it. - The SIGINT handler was never restored on the new break path, so after a
Ctrl+C in
INPUT$the REPL's own Ctrl+C - including three-presses-to-quit - was dead for the rest of the session. Restored on both break arms. - Windows read the physical console even when stdin was redirected, so
mbasic prog.bas < in.txtwould have blocked on a keypress that never comes.isatty()first, now.
Two test defects came out of the same review: the RDY sync marker matched the
echo of the source line that printed it (10 PRINT "RDY";), which flaked 2 runs
in 13 under load, and a failing run took ~58s - past run_regression.py's 30s
timeout, whose handler discards the captured output, so a future regression
would have reported nothing at all.
Verifying¶
python3 tests/regression/interpreter/test_input_dollar_posix.py
32 checks in about 7 seconds: single keystroke with no Enter and no echo,
INPUT$(3) in one burst, no stale keystroke into the next RUN, type-ahead
typed before the statement, CHR$(1)/CHR$(13)/high bytes passed through,
Ctrl+C breaking and CONT resuming, one Ctrl+C ending a three-character read,
type-ahead surviving a break, the terminal surviving a SIGTERM mid-read,
Ctrl+C in immediate mode, and both piped cases. Against the code before the fix
it reports 18 failures in ~22s, naming each of the four symptoms plus
Break in PC(30.0).
Speed comes from drain(..., until=marker): without it every step pays its
full timeout, since a quiet interpreter and a hung one look identical until the
deadline expires. Every read of the pty master is select()-gated against a
deadline, so a hang fails the test instead of hanging the runner - and every
deadline is additionally clamped to a whole-file budget (BUDGET_SECONDS, 20s)
so that a failing run still finishes inside the 30s the runner allows and
prints its failure list.
Tests that type at an INPUT$ prompt wait for the program to print a CHR$(6)
marker and then pause RAW_MODE_SETTLE (0.2s). The marker is a control byte on
purpose: the CLI echoes a typed-ahead program line twice, once by the line
discipline and once by readline, so a printable marker matches the echo of its
own source line and the sync lands before the program has run. The pause covers
the few hundred microseconds between the marker being flushed and
tty.setraw() - typing inside that window is a race a human cannot win, and
the keystroke would be echoed and, if it is Enter, rewritten to LF by ICRNL.
Known limitations¶
A key typed before INPUT$ starts carries the cooked terminal's
translations. It is still delivered - that is what TCSANOW protects - but
it passed through the line discipline on the way in, so an early Enter arrives
as CHR$(10) where one typed at the prompt arrives as CHR$(13). Fixing that
would mean holding the terminal in cbreak for the whole program run, which is
the same underlying gap that stops a bare 10 A$=INKEY$ loop from seeing
keystrokes (see WINDOWS_CONSOLE_KEYS.md).
Leftover type-ahead now goes back to the REPL. Typing AB and Enter at
INPUT$(1) leaves B and the terminator in the tty queue, where the REPL
reads them and reports Unknown statement or command: 'b'. That is the honest
outcome - the same as typing ahead at the Ok prompt - and it is what stops
the byte from surfacing two RUNs later. It is not silently discarded, because
discarding input is the bug this family of fixes exists to remove.
A terminal read returns bytes; a piped read returns characters. The raw
path decodes latin-1, so INPUT$(n) is n bytes from a tty. The cooked path
is still sys.stdin.read(1), which is n decoded characters - so typing
e-acute at INPUT$(1) gives CHR$(195) (the first UTF-8 byte, the second left
in the queue) while piping the same two bytes gives CHR$(233). Reading
sys.stdin.buffer instead would fix the type at the cost of desynchronising
from the wrapper the REPL and INPUT read through, which is the stranding bug
in reverse - a worse trade for input that is almost always ASCII.
Windows: a bare 0xFF keypress with nothing behind it looks like "no
console". win_read_key cannot tell them apart (its own comment concedes
this), and INPUT$ is now the only live blocking caller. On a misfire the read
falls through to the cooked path - a line read, waiting for Enter - rather than
failing. Untypeable in practice; recorded because the heuristic is now
reachable.
Windows: half a special key can be left in _win_pending. An arrow expands
to three characters and INPUT$(1) takes one, leaving the rest in a
module-level list that only the next builtin read sees. POSIX leaves the same
remainder in the tty queue, where the REPL reports it. Pre-existing in
INKEY$'s design; Interpreter.start() clears it between programs.
Not fixed here¶
Fixed since - see
KEY_INPUT_ROUTING.md. IOHandler.input_char() is dead API.INKEY$ and INPUT$ now read
through the I/O handler, and the terminal machinery described above moved to
ConsoleIOHandler, which is where this document's own references to
_read_console and _check_break should now be read: same code, same
behavior, one layer down. What was recorded here at the time:
Seven implementations exist behind one @abstractmethod, and nothing reaches
any of them - the only call site in src/ is web_io.py's own deprecated
get_char() alias, which has no callers either. src/iohandler/base.py,
src/terminal_errors.py and CLI_INPUT_HANDLING_FIXES.md all describe
ConsoleIOHandler.input_char as "the INPUT$ reader", and it never was.
BuiltinFunctions is constructed with only the runtime, so INPUT$ and
INKEY$ cannot reach the interpreter's I/O handler at all. The consequence is
that under the curses, web and Tk backends both builtins read the server or
launching terminal's sys.stdin rather than the backend's input - measured
with a CapturingIOHandler whose input_char returns "": INPUT$(1) still
returned the byte piped to the process.
Immediate-mode INPUT (the statement, not the function) still does not
work - see the same note in
CLI_INPUT_HANDLING_FIXES.md.
The compiler backends have no INPUT$ reader. The C backend emits
/* unsupported function */; the JS backend passes the name through and emits
literal input$(...). Nothing here changes that.