Fixed assembler bug that caused crash on IR array resize. Added line editor app.
This commit is contained in:
Executable
+259
@@ -0,0 +1,259 @@
|
||||
#!/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; }
|
||||
"$ROOT/Assembler" "$BUILD/prompt.asm" -o "$BUILD/prompt.bin" >/dev/null 2>&1 || {
|
||||
echo "Could not assemble the terminal test programs."; exit 1; }
|
||||
|
||||
python3 - "$ROOT" "$BUILD" <<'PY'
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
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 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")
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user