An arrow key has never reached this machine. Voyager threw it away for want of anywhere to put it, and a terminal sent ESC [ A, which arrived in the middle of whatever was being read and made it unrecognisable - typing Up at the CosmOS prompt put three bytes in the command line and got "I do not know". So the console names them: one byte each, 0x80 upward, above ASCII so nothing written before them can collide. Up, Down, Left, Right, Home, End and forward Delete, with room above for the paging and function keys. The console normalises, which is what it already does. Behind a window it turns the key somebody pressed into a byte; on a terminal it turns the sequence into the same byte. That is the act it has always performed on Return and Backspace, one layer further along, and it is why a program need not know which of the two it is talking to. What a key MEANS is not the console's business - that belongs to whoever is reading, the same way what is on a disk belongs to the system and what a drive is belongs to the machine. Translated only when standard input really is a terminal. Nothing else sends these sequences, a pipe holds exactly the bytes somebody put in it, and it keeps the Escape-or-Up timing problem out of every test here: a test writes the key values themselves. Line mode drops them, in both front ends, because line mode delivers characters and a line somebody else has finished editing cannot be moved about in. Press.sbx says what it was handed, in hexadecimal and by name, and reads a line before it reads keys so both halves of that rule are checked. Two recordings, one fed as standard input and one as a keyboard, agreeing byte for byte; each break fails exactly one of them. Three checks in terminal.sh type real escape sequences at a pseudo-terminal, which is the only place they are ever read as sequences: that they arrive as keys, that Escape alone is still Escape, and that a character typed straight after an escape is held rather than swallowed. Five recordings re-blessed for Press.sbx appearing on the shared disk, and the whole of that diff is the file's own line and the counts above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E2JrLzFvuFX9fgi1LDRjrW
459 lines
18 KiB
Bash
Executable File
459 lines
18 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Checks the things a recorded output cannot see.
|
|
#
|
|
# Every other test in this suite pipes standard input in and standard output to a file, and
|
|
# compares what came out against what came out last time. That answers "what does this
|
|
# program print", which is the right question almost always, and it is blind to two whole
|
|
# classes of behaviour:
|
|
#
|
|
# WHEN something is printed. Piped output is fully buffered and flushed when the process
|
|
# ends, so a prompt that appears before input is read and a prompt that appears an hour
|
|
# later produce byte-identical files. A prompt printed after the answer it was asking for
|
|
# is invisible to every other test here.
|
|
#
|
|
# WHAT HAPPENS TO THE TERMINAL. Key mode only touches a terminal when there is one, so
|
|
# with input from a file there is nothing to put into another state and nothing to put
|
|
# back. A machine that leaves the terminal without echo passes all 92 other tests.
|
|
#
|
|
# Both of those have gone wrong in this repository, and both were found by a person whose
|
|
# terminal stopped working rather than by anything here. So this runs the emulator under a
|
|
# pseudo-terminal, which is what makes those questions askable at all.
|
|
#
|
|
# Written by Anachronaut
|
|
|
|
set -u
|
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
|
cd "$ROOT" || exit 1
|
|
|
|
BUILD="$ROOT/Tests/build/terminal"
|
|
mkdir -p "$BUILD"
|
|
|
|
[ -x "$ROOT/SplitBit" ] || { echo "The emulator is not built."; exit 1; }
|
|
[ -x "$ROOT/Assembler" ] || { echo "The assembler is not built."; exit 1; }
|
|
|
|
# A program that asks for key mode and then waits for a key that never comes. Every check
|
|
# below needs a machine that is sitting in key mode with the terminal in its hands.
|
|
cat > "$BUILD/keywait.asm" <<'ASM'
|
|
#Program
|
|
start:
|
|
INIA 0x01
|
|
OUTA 0x02
|
|
INA 0x00
|
|
HALT
|
|
ASM
|
|
|
|
# A program that prints something with no newline after it and then waits, which is the
|
|
# shape of a prompt and the shape the buffering problem hides in.
|
|
cat > "$BUILD/prompt.asm" <<'ASM'
|
|
#Program
|
|
start:
|
|
INIA 0d62 ; '>'
|
|
OUTA 0x00
|
|
INIA 0x20
|
|
OUTA 0x00
|
|
INA 0x00
|
|
HALT
|
|
ASM
|
|
|
|
"$ROOT/Assembler" "$BUILD/keywait.asm" -o "$BUILD/keywait.bin" >/dev/null 2>&1 || {
|
|
echo "Could not assemble the terminal test programs."; exit 1; }
|
|
|
|
# ---- And a program that says which key it was given ----
|
|
#
|
|
# A terminal sends ESC [ A for the Up key and the console turns that into one byte of its
|
|
# own. NOTHING BUT A REAL TERMINAL CAN TEST THAT: the translation deliberately happens only
|
|
# when standard input is one, so every recorded test in the suite - which feed a file - goes
|
|
# straight past it and would pass against a console that translated nothing at all.
|
|
#
|
|
# It prints a letter per key rather than the byte, because the bytes are not printable and
|
|
# a recorded escape byte is no easier to read than the sequence it came from.
|
|
cat > "$BUILD/escape.asm" <<'ASM'
|
|
#Program
|
|
start:
|
|
INIA 0x01
|
|
OUTA 0x02 ; Key mode, or the terminal holds everything until Return.
|
|
CALL show
|
|
CALL show
|
|
CALL show
|
|
RSTA
|
|
OUTA 0x02
|
|
HALT
|
|
|
|
; One key, named. XOR leaves the answer in Q and A alone, so one read stands for the ladder.
|
|
show:
|
|
INA 0x00
|
|
INIB 0x80
|
|
XOR
|
|
BRQ showUp
|
|
INIB 0x82
|
|
XOR
|
|
BRQ showLeft
|
|
INIB 0x86
|
|
XOR
|
|
BRQ showDelete
|
|
INIB 0x1B
|
|
XOR
|
|
BRQ showEscape
|
|
INIA 0x3F ; '?', for anything else.
|
|
OUTA 0x00
|
|
RET
|
|
showUp:
|
|
INIA 0x55 ; 'U'
|
|
OUTA 0x00
|
|
RET
|
|
showLeft:
|
|
INIA 0x4C ; 'L'
|
|
OUTA 0x00
|
|
RET
|
|
showDelete:
|
|
INIA 0x44 ; 'D'
|
|
OUTA 0x00
|
|
RET
|
|
showEscape:
|
|
INIA 0x45 ; 'E'
|
|
OUTA 0x00
|
|
RET
|
|
ASM
|
|
|
|
"$ROOT/Assembler" "$BUILD/escape.asm" -o "$BUILD/escape.bin" >/dev/null 2>&1 || {
|
|
echo "Could not assemble the terminal test programs."; exit 1; }
|
|
"$ROOT/Assembler" "$BUILD/prompt.asm" -o "$BUILD/prompt.bin" >/dev/null 2>&1 || {
|
|
echo "Could not assemble the terminal test programs."; exit 1; }
|
|
|
|
# And the one that waits. Assembled from the repository rather than written inline, because
|
|
# it is a real test program that run.sh also runs - there it proves the machine wakes up at
|
|
# all, and here it proves it was asleep.
|
|
"$ROOT/Assembler" "$ROOT/Programs/testPrograms/timerBeatTest.asm" \
|
|
-o "$BUILD/timerBeatTest.bin" >/dev/null
|
|
"$ROOT/Assembler" "$ROOT/Programs/testPrograms/waitTest.asm" -o "$BUILD/waitTest.bin" \
|
|
>/dev/null 2>&1 || { echo "Could not assemble waitTest."; exit 1; }
|
|
"$ROOT/SplitDisk" format "$BUILD/wait.img" 32 1 >/dev/null 2>&1 || {
|
|
echo "Could not make the disk waitTest reads."; exit 1; }
|
|
|
|
python3 - "$ROOT" "$BUILD" <<'PY'
|
|
import os
|
|
import pty
|
|
import select
|
|
import signal
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
root, build = sys.argv[1], sys.argv[2]
|
|
emulator = os.path.join(root, "SplitBit")
|
|
|
|
passed = 0
|
|
problems = []
|
|
|
|
|
|
def report(ok, what, detail=""):
|
|
global passed
|
|
if ok:
|
|
passed += 1
|
|
print(" [ok ] %s" % what)
|
|
else:
|
|
problems.append(what if not detail else "%s: %s" % (what, detail))
|
|
print(" [FAIL] %s%s" % (what, (" - " + detail) if detail else ""))
|
|
|
|
|
|
def underPty(script, seconds=10):
|
|
"""Runs a shell script with a pseudo-terminal for its controlling terminal, and
|
|
gives back everything the terminal saw."""
|
|
pid, fd = pty.fork()
|
|
if pid == 0:
|
|
os.execv("/bin/bash", ["/bin/bash", "-c", script])
|
|
seen = b""
|
|
end = time.time() + seconds
|
|
while time.time() < end:
|
|
ready, _, _ = select.select([fd], [], [], 0.2)
|
|
if ready:
|
|
try:
|
|
chunk = os.read(fd, 4096)
|
|
except OSError:
|
|
break
|
|
if not chunk:
|
|
break
|
|
seen += chunk
|
|
if os.waitpid(pid, os.WNOHANG)[0]:
|
|
break
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
os.waitpid(pid, 0)
|
|
except ChildProcessError:
|
|
pass
|
|
os.close(fd)
|
|
return seen
|
|
|
|
|
|
# ---- A prompt is shown before the answer to it is asked for ----
|
|
#
|
|
# The machine writes "> " and then waits. Nothing more will ever be printed, so if the two
|
|
# characters have not arrived after a second of waiting, they are sitting in a buffer and
|
|
# the person at the terminal is looking at nothing and being asked to answer it.
|
|
#
|
|
# This is exactly the bug that getchar used to hide: reading through stdio flushed the line
|
|
# buffered streams first, and reading with read() does not.
|
|
pid, fd = pty.fork()
|
|
if pid == 0:
|
|
os.execv(emulator, [emulator, "--fast", os.path.join(build, "prompt.bin")])
|
|
seen = b""
|
|
end = time.time() + 1.5
|
|
while time.time() < end:
|
|
ready, _, _ = select.select([fd], [], [], 0.2)
|
|
if ready:
|
|
try:
|
|
seen += os.read(fd, 1024)
|
|
except OSError:
|
|
break
|
|
report(b">" in seen, "a prompt is shown before its answer is read",
|
|
"" if b">" in seen else "nothing arrived in 1.5s, so it is stuck in a buffer")
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
os.waitpid(pid, 0)
|
|
except (ProcessLookupError, ChildProcessError):
|
|
pass
|
|
os.close(fd)
|
|
|
|
|
|
# ---- A key arrives without Return ----
|
|
#
|
|
# Which is the whole of what key mode is for. In line mode the terminal holds what is typed
|
|
# until Return, so a machine that failed to ask for key mode would wait here for ever.
|
|
pid, fd = pty.fork()
|
|
if pid == 0:
|
|
os.execv(emulator, [emulator, "--fast", os.path.join(build, "keywait.bin")])
|
|
time.sleep(0.5)
|
|
os.write(fd, b"x") # No newline, on purpose.
|
|
finished = False
|
|
end = time.time() + 2
|
|
while time.time() < end:
|
|
if os.waitpid(pid, os.WNOHANG)[0]:
|
|
finished = True
|
|
break
|
|
time.sleep(0.05)
|
|
report(finished, "a single keystroke arrives without Return",
|
|
"" if finished else "the machine was still waiting, so the key was held by the terminal")
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
os.waitpid(pid, 0)
|
|
except (ProcessLookupError, ChildProcessError):
|
|
pass
|
|
os.close(fd)
|
|
|
|
|
|
# ---- The keys that are not characters, as a terminal actually sends them ----
|
|
#
|
|
# A window hands the console the key somebody pressed. A terminal hands it ESC [ A and
|
|
# expects the far end to know what that means, and the console is the far end. Every other
|
|
# test in this suite feeds a file, where the translation deliberately does not happen, so
|
|
# this is the only place the sequences are ever read as sequences.
|
|
def typedAt(typing, seconds=4):
|
|
"""Runs the naming program under a pseudo-terminal, types at it, and gives back the
|
|
letters it printed."""
|
|
pid, fd = pty.fork()
|
|
if pid == 0:
|
|
os.execv(emulator, [emulator, "--fast", os.path.join(build, "escape.bin")])
|
|
# The machine has to have asked for key mode before anything is typed at it. Until it
|
|
# does, the terminal is still holding what arrives until Return and the sequences would
|
|
# sit in it unread.
|
|
time.sleep(0.5)
|
|
for chunk, pause in typing:
|
|
os.write(fd, chunk)
|
|
time.sleep(pause)
|
|
seen = b""
|
|
end = time.time() + seconds
|
|
while time.time() < end:
|
|
ready, _, _ = select.select([fd], [], [], 0.2)
|
|
if ready:
|
|
try:
|
|
data = os.read(fd, 1024)
|
|
except OSError:
|
|
break
|
|
if not data:
|
|
break
|
|
seen += data
|
|
elif os.waitpid(pid, os.WNOHANG)[0]:
|
|
break
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
os.waitpid(pid, 0)
|
|
except (ProcessLookupError, ChildProcessError):
|
|
pass
|
|
os.close(fd)
|
|
# Everything before the emulator says how it stopped. The letters have no newline after
|
|
# them, so they arrive stuck to whatever the machine printed on its way out.
|
|
return seen.split(b"Execution")[0].strip()
|
|
|
|
|
|
seen = typedAt([(b"\x1b[A", 0.2), (b"\x1b[D", 0.2), (b"\x1b[3~", 0.2)])
|
|
report(seen == b"ULD", "a terminal's escape sequences arrive as keys",
|
|
"" if seen == b"ULD" else "expected ULD, saw %r" % seen)
|
|
|
|
# ---- And pressing Escape is still pressing Escape ----
|
|
#
|
|
# Which is the whole difficulty: Up and Escape both begin with 0x1B and the only thing
|
|
# telling them apart is whether anything follows immediately. A console that waited for the
|
|
# rest of a sequence that was never coming would swallow the key; one that did not wait at
|
|
# all would never see a sequence.
|
|
seen = typedAt([(b"\x1b", 0.3), (b"\x1b", 0.3), (b"\x1b", 0.3)])
|
|
report(seen == b"EEE", "Escape on its own is still Escape",
|
|
"" if seen == b"EEE" else "expected EEE, saw %r" % seen)
|
|
|
|
# ---- And what follows an escape that was not a sequence is not eaten ----
|
|
#
|
|
# Escape and then an ordinary character, close enough together to look like one thing. It is
|
|
# two, and the second one is somebody's keystroke: the console holds it and hands it over
|
|
# next rather than throwing it away with the sequence that never was.
|
|
seen = typedAt([(b"\x1ba", 0.3), (b"\x1b", 0.3)])
|
|
report(seen == b"E?E", "a character after an escape is not swallowed",
|
|
"" if seen == b"E?E" else "expected E?E, saw %r" % seen)
|
|
|
|
|
|
# ---- The terminal is handed back however the machine dies ----
|
|
#
|
|
# atexit covers stopping on purpose and nothing else: it does not run when a process is
|
|
# killed by a signal. SIGHUP is the one that matters most, because it is what arrives when
|
|
# whatever launched the machine dies and takes the terminal with it - and a terminal left
|
|
# in key mode has no echo and no line editing, which is a far worse failure than anything
|
|
# the program was doing.
|
|
for name in ("HUP", "INT", "QUIT", "ABRT", "SEGV", "TERM"):
|
|
# Cleared first. Without this a run that dies before it can measure leaves the PREVIOUS
|
|
# signal's files in place, and the check compares those and passes - which is how the
|
|
# SIGQUIT case came to be reporting success while proving nothing at all.
|
|
for leftover in ("before.txt", "after.txt"):
|
|
try:
|
|
os.remove(os.path.join(build, leftover))
|
|
except FileNotFoundError:
|
|
pass
|
|
script = (
|
|
# No core files. Two of these signals dump core by default, and a test suite has no
|
|
# business leaving those around every time it runs. It also keeps the measuring
|
|
# shell alive through SIGQUIT, which otherwise takes it down before it can look.
|
|
"ulimit -c 0\n"
|
|
"stty -g > {b}/before.txt\n"
|
|
"{e} --fast {b}/keywait.bin < /dev/tty &\n"
|
|
"P=$!\n"
|
|
"sleep 0.5\n"
|
|
"kill -{s} $P 2>/dev/null\n"
|
|
"wait $P 2>/dev/null\n"
|
|
"sleep 0.3\n"
|
|
"stty -g > {b}/after.txt\n"
|
|
).format(b=build, e=emulator, s=name)
|
|
# < /dev/tty is load bearing. A background job in a non-interactive shell gets its
|
|
# standard input from /dev/null, so without it the machine never sees a terminal, never
|
|
# enters key mode, and has nothing to fail to put back - and this check would pass
|
|
# against a machine that restores nothing at all.
|
|
underPty(script)
|
|
try:
|
|
before = open(os.path.join(build, "before.txt")).read().strip()
|
|
after = open(os.path.join(build, "after.txt")).read().strip()
|
|
except FileNotFoundError:
|
|
# A failure, and for SIGQUIT this is the shape the failure takes: a machine that
|
|
# does not handle it dies in a way that takes the measuring shell with it, so what
|
|
# is reported is not "the terminal was left wrong" but "nothing got as far as
|
|
# looking". Both mean the same thing here, which is that the signal is unhandled.
|
|
report(False, "the terminal is restored after SIG%s" % name, "could not measure")
|
|
continue
|
|
report(before == after, "the terminal is restored after SIG%s" % name,
|
|
"" if before == after else "left as %s, was %s" % (after[:24], before[:24]))
|
|
|
|
|
|
# ---- Suspending is not dying ----
|
|
#
|
|
# Ctrl-Z has to hand the terminal back while the machine is stopped, because whoever gets it
|
|
# next is entitled to find it as they left it, and take key mode again on resume, because
|
|
# the machine has not finished with it.
|
|
script = (
|
|
"ulimit -c 0\n"
|
|
"stty -g > {b}/t0.txt\n"
|
|
"{e} --fast {b}/keywait.bin < /dev/tty &\n"
|
|
"P=$!\n"
|
|
"sleep 0.5\n"
|
|
"kill -TSTP $P; sleep 0.4\n"
|
|
"stty -g > {b}/t1.txt\n"
|
|
"kill -CONT $P; sleep 0.4\n"
|
|
"stty -g > {b}/t2.txt\n"
|
|
"kill -TERM $P; sleep 0.2\n"
|
|
).format(b=build, e=emulator)
|
|
underPty(script)
|
|
try:
|
|
t0 = open(os.path.join(build, "t0.txt")).read().strip()
|
|
t1 = open(os.path.join(build, "t1.txt")).read().strip()
|
|
t2 = open(os.path.join(build, "t2.txt")).read().strip()
|
|
report(t1 == t0, "the terminal is handed back while suspended")
|
|
report(t2 != t0, "key mode is taken again on resume")
|
|
except FileNotFoundError:
|
|
report(False, "suspending and resuming", "could not measure")
|
|
|
|
# ---- Waiting is not the same as spinning ----
|
|
#
|
|
# settle() strips cycle counts out of every recorded output, so no test in run.sh can see
|
|
# the difference between a machine that slept through a slow disk and one that spun on it.
|
|
# The two print the same characters and take the same elapsed time. What separates them is
|
|
# WHICH KIND of cycle went by, and that is only visible here.
|
|
#
|
|
# waitTest reads two blocks from a disk given ten thousand cycles of latency, with
|
|
# interrupts masked and no handler installed. Nearly all of the run should be idle. With
|
|
# the line-clearing removed from WAIT the total barely moves - it was 20,100 against
|
|
# 20,099 - and the idle count halves, because the second wait finds the first wait's line
|
|
# still standing and returns at once.
|
|
waitProgram = os.path.join(build, "waitTest.bin")
|
|
waitDisk = os.path.join(build, "wait.img")
|
|
if os.path.exists(waitProgram) and os.path.exists(waitDisk):
|
|
run = subprocess.run([emulator, waitProgram, "--fast", "--cycles", "5000000",
|
|
"--disk", waitDisk, "--disk-cycles", "10000"],
|
|
capture_output=True, text=True)
|
|
got = re.search(r"halted after (\d+) cycles, (\d+) of them waiting", run.stdout)
|
|
if not got:
|
|
report(False, "a slow disk is waited for", "no idle cycles were reported at all")
|
|
else:
|
|
total, idle = int(got.group(1)), int(got.group(2))
|
|
report("12" in run.stdout, "both reads finished", "%d cycles" % total)
|
|
# Two waits of ten thousand cycles each. Sleeping through both puts idle over
|
|
# nine tenths of the run; spinning through either drops it to about half.
|
|
report(idle > total * 0.9, "and the machine slept rather than spun",
|
|
"%d of %d idle (%.0f%%)" % (idle, total, 100.0 * idle / total))
|
|
else:
|
|
report(False, "a slow disk is waited for", "waitTest.bin or the disk is missing")
|
|
|
|
# ---- A period is a number of cycles, and this is where that can be said ----
|
|
#
|
|
# The manifest records what a program printed with the cycle count stripped, which is right
|
|
# for everything else and useless for a clock: the whole claim the timer makes is about HOW
|
|
# LONG, and a recording that says "it printed eight dots" would pass on a timer that fired
|
|
# them all at once.
|
|
#
|
|
# Eight periods of 125,000 is a million cycles, and the program does nothing else worth
|
|
# counting. Within a couple of hundred, because starting and stopping cost a few instructions
|
|
# and the last tick is answered rather than waited for.
|
|
beatProgram = os.path.join(build, "timerBeatTest.bin")
|
|
if os.path.exists(beatProgram):
|
|
run = subprocess.run([emulator, beatProgram, "--fast", "--cycles", "5000000"],
|
|
capture_output=True, text=True)
|
|
got = re.search(r"halted after (\d+) cycles, (\d+) of them waiting", run.stdout)
|
|
if not got:
|
|
report(False, "eight beats take eight periods", "no cycle count was reported")
|
|
else:
|
|
total, idle = int(got.group(1)), int(got.group(2))
|
|
report(abs(total - 1000000) < 500, "eight beats take eight periods",
|
|
"%d cycles against 8 x 125,000" % total)
|
|
# And it slept through them rather than counting. A timer a program has to poll is
|
|
# a timer that costs the machine everything it saves.
|
|
report(idle > total * 0.99, "and the machine slept between them",
|
|
"%d of %d idle (%.1f%%)" % (idle, total, 100.0 * idle / total))
|
|
else:
|
|
report(False, "eight beats take eight periods", "timerBeatTest.bin is missing")
|
|
|
|
print()
|
|
if problems:
|
|
print("The terminal does not survive everything it should:")
|
|
for p in problems:
|
|
print(" " + p)
|
|
sys.exit(1)
|
|
print("All %d terminal checks passed." % passed)
|
|
PY
|