Skip to content

Undoing a statement that has to be run again

Created: 2026-08-05 Status: Fixed - src/statement_attempt.py (new), src/interpreter.py, src/basic_builtins.py, src/runtime.py Regression test: tests/regression/interpreter/test_statement_attempt.py

A statement that pauses for a key is re-executed from the start (WEB_PROGRAM_KEYBOARD.md), so whatever the abandoned attempt already did happens twice. This is the rest of that story: what a second attempt repeats, and what is now put back first.

What actually repeats

Measured rather than assumed, and the first version of this list - written into WEB_PROGRAM_KEYBOARD.md before anything was measured - was wrong on its headline claim:

PRINT "A";INPUT$(1)      A is printed ONCE, not once per attempt
X$=INPUT$(1)+INPUT$(1)   a key eaten per attempt - never completed
X$=STR$(RND)+INPUT$(1)   a random number drawn per attempt
X$=INPUT$(1,1)+INPUT$(1) a file byte read per attempt

Output does not duplicate because execute_print evaluates its expressions into a list and writes at the end - an attempt that pauses mid-expression has written nothing. The other three are real, and the first of them was the worst: the program ate every key it was given and never reached the next line.

Keys were fixed by KeyReadTransaction (see WEB_PROGRAM_KEYBOARD.md). The other two are what this document is about.

RND

10 X$=STR$(RND)+INPUT$(1)

Pausing three times drew four random numbers and used the last. The value the program got was fine; the sequence was not, and a sequence is the only thing a random number generator is for. A program that waited for the user skipped forward by however many times it happened to pause - which is neither reproducible nor visible.

StatementAttempt.note_random snapshots random.getstate() and runtime.rnd_last before the first draw of an attempt, and a rollback restores both. Once per attempt, not once per draw: the state to go back to is the one the attempt started from, however many numbers it drew.

INPUT$ from a file

10 X$=INPUT$(1,1)+INPUT$(1)

The file read succeeded and advanced the handle; the keyboard read paused; the retry read the next byte and paired it with the key. The file was left one byte further on than the program had ever seen, so its next read was short.

note_file_position records tell() the first time each file is read in an attempt, and a rollback seeks back. Per file, because one statement can read two.

What is deliberately not undone

EOF sets file_info['eof'] when it reaches the end of a file. Setting it twice is the same as setting it once, and a BASIC program cannot tell the difference.

Variable assignment happens after the expression is evaluated, so a statement that pauses has not assigned anything. INPUT, GET, PUT and POKE are statements of their own - a statement that pauses for a key contains a keyboard read, and those do not.

The cost, and who pays it

Nothing, unless the I/O handler can pause a statement:

deferring = getattr(self.io, 'defers_key_reads', False)
if deferring:
    self.io.begin_key_transaction()
    ...
    self.runtime.statement_attempt = self._statement_attempt

Only the web backend sets that flag. On a terminal the attribute lookup is the whole cost, and runtime.statement_attempt stays None, so RND and the file read skip their recording with one getattr each. Measured: 180,000 statements run in the same 7-8 seconds before and after, with the difference well inside the run-to-run variance of this machine.

The attempt object is built once per interpreter and reset per statement rather than allocated each time.

A bug found on the way

INPUT$(n,#f) returned a bytes object. Mode I files are opened 'rb' so EOF can spot a CP/M ^Z, and the read was handed to the program unchanged:

PRINT INPUT$(3,#1)              b'ABC'
PRINT ASC(INPUT$(1,#1))         98      <- the 'b' of the repr

98 is ASC("b"). The help page's own example for INPUT$ is PRINT HEX$(ASC(INPUT$(1, #1)));, so the documented use of the function was broken. It decodes latin-1 now - byte-transparent, the same as every other read in this family - and answers 65.

Verifying

python3 tests/regression/interpreter/test_statement_attempt.py

14 checks. Four drive StatementAttempt directly (the generator snapshotted once per attempt and not per draw, RND(0)'s value restored, file positions rewound, a committed attempt not undone by the next one). Four run real programs through the interpreter with a handler that pauses: the same random number whether it paused three times or none, a file read that is not repeated, the bytes fix, and a control showing a non-deferring handler never gets an attempt at all - a regression that switched this on everywhere would otherwise be invisible.