run_com_exec.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369
  1. #!/usr/bin/env python3
  2. """run_com_exec.py -- execute a TP3-compiled .COM and assert its exact output.
  3. This is the test the project could not write until now. Everything before it
  4. was a claim about the *image*; this is a claim about the *program*. A .COM
  5. that this compiler produced is booted on a real 8086 under qemu-system-i386,
  6. and the exact bytes it writes are asserted against a file.
  7. .pas --Compiler.mod--> image --Linker.mod--> .COM
  8. .COM --bootcom.s--> floppy image --qemu--> serial bytes --assert-->
  9. Nothing here trusts the compiler, the linker, or the boot sector:
  10. * the floppy image is built here, byte by byte;
  11. * the load address and the input descriptor's offset are *restated* from
  12. bootcom.s's own layout block, and this file refuses to run if they have
  13. drifted out of what the boot sector actually reads;
  14. * "the program exited" is not a marker in the output stream -- it is qemu's
  15. exit code, via the isa-debug-exit device. So no sentinel byte can ever
  16. collide with real program output, and every byte on the serial port is
  17. program output with nothing to strip;
  18. * the exit code's VALUE is asserted too, not just that there was one. Most
  19. fixtures end at progend, which exits 0; a fixture that should leave through
  20. the runtime error path instead (strcat's overflow exits 16) says so in a
  21. `.exit' file next to its `.out';
  22. * the expected output is a file per fixture, next to the fixture, so a
  23. wrong expectation shows up as a diff instead of an inline string that
  24. gets quietly edited to match.
  25. Usage:
  26. tests/run_com_exec.py # every fixture with a .out file
  27. tests/run_com_exec.py t02_writeln # one, by name
  28. tests/run_com_exec.py --list
  29. tests/run_com_exec.py --show t02 # print what came out, assert nothing
  30. `--rebless` regenerates the .out files from what the machine actually did.
  31. It exists because a project that cannot regenerate its expectations cannot
  32. tell the difference between a bug and a stale file -- but it is never the way
  33. to fix a red test, and the summary it prints says how many files it rewrote.
  34. """
  35. import os
  36. import subprocess
  37. import sys
  38. import tempfile
  39. HERE = os.path.dirname(os.path.abspath(__file__))
  40. SHELL = os.path.dirname(HERE)
  41. GM2 = "/home/eric/bin/Modula2/Gm2/bin/gm2"
  42. QEMU = "/usr/bin/qemu-system-i386"
  43. TIMEOUT = 15
  44. # --------------------------------------------------------------- the layout
  45. # Restated from tests/exec/bootcom.s on purpose. A test that asks the code
  46. # under test where things are proves only that the code agrees with itself,
  47. # so these are written out here and checked against each other; if the
  48. # assembly's layout changes, the mismatch shows up as a loud failure rather
  49. # than as a program loaded somewhere harmless.
  50. LOAD_SEG = 0x1000 # the .COM's load segment
  51. LOAD_OFF = 0x0100 # ...and offset; DOS puts a .COM at CS:0100
  52. FLAT_LOAD = LOAD_SEG * 16 + LOAD_OFF
  53. INLEN_OFF = 0x9000 # word: how many input bytes follow
  54. INBUF_OFF = 0x9004 # the input bytes
  55. INBUF_MAX = 0x00FC # 7100h - 7004h
  56. SECTORS = 72 # what bootcom.s reads off the disk
  57. FLOPPY_SECTORS = 2880 # 1.44M, 18 sectors per track
  58. FLOPPY_BYTES = FLOPPY_SECTORS * 512
  59. COM_MAX = INLEN_OFF - LOAD_OFF # the image must end before the input
  60. # The descriptor has to be inside the region the boot sector reads, or the
  61. # boot sector reads whatever the BIOS left there. This is a check on the two
  62. # constant blocks agreeing, and it is the reason they are both written down.
  63. READ_END = FLAT_LOAD + SECTORS * 512
  64. INLEN_FLAT = LOAD_SEG * 16 + INLEN_OFF
  65. INBUF_FLAT = LOAD_SEG * 16 + INBUF_OFF
  66. if INBUF_FLAT + INBUF_MAX > READ_END:
  67. sys.exit("run_com_exec.py: the input descriptor at %04X..%04X runs past the "
  68. "%d sectors bootcom.s reads (which end at %04X)"
  69. % (INBUF_OFF, INBUF_OFF + INBUF_MAX, SECTORS, READ_END))
  70. # ...and past the end of any .COM, or it would land inside the program.
  71. if FLAT_LOAD + COM_MAX > INLEN_FLAT:
  72. sys.exit("run_com_exec.py: COM_MAX ends at %04X, past the input "
  73. "descriptor at %04X"
  74. % (FLAT_LOAD + COM_MAX, INBUF_OFF))
  75. # isa-debug-exit at port 0501h. qemu exits with (value<<1)|1, so a program
  76. # that exits 0 gives rc=1 and a program that exits n gives (n<<1)|1. Both
  77. # mean "ran to completion"; anything else means it never got there.
  78. EXIT_BASE = 1
  79. def build_boot_sector(load_seg=None, inlen=None, copy_bytes=None, scratch=None):
  80. """Assemble tests/exec/bootcom.s and return its 512 bytes."""
  81. with tempfile.TemporaryDirectory() as td:
  82. obj, raw = os.path.join(td, "b.o"), os.path.join(td, "b.bin")
  83. cmd = ["as", "--32"]
  84. if load_seg is not None:
  85. cmd += ["--defsym", "LOAD_SEG=%d" % load_seg]
  86. if inlen is not None:
  87. cmd += ["--defsym", "INLEN=%d" % inlen]
  88. if copy_bytes is not None:
  89. cmd += ["--defsym", "COPY_BYTES=%d" % copy_bytes]
  90. if scratch is not None:
  91. cmd += ["--defsym", "SCRATCH_SEG=%d" % scratch]
  92. cmd += ["-o", obj, os.path.join(HERE, "exec", "bootcom.s")]
  93. r = subprocess.run(cmd, capture_output=True)
  94. if r.returncode != 0:
  95. sys.exit("as failed:\n" + r.stderr.decode(errors="replace"))
  96. r = subprocess.run(["objcopy", "-O", "binary", obj, raw],
  97. capture_output=True)
  98. if r.returncode != 0:
  99. sys.exit("objcopy failed:\n" + r.stderr.decode(errors="replace"))
  100. data = open(raw, "rb").read()
  101. if len(data) != 512:
  102. sys.exit("the boot sector is %d bytes, not 512" % len(data))
  103. if data[510:512] != b"\x55\xaa":
  104. sys.exit("the boot sector has no 55 AA signature")
  105. return data
  106. def to_file(mem_off):
  107. """Where a SEGMENT-relative address ends up in the disk image.
  108. bootcom.s reads disk sector 2 (1-based, so file offset 512) into
  109. 0000:0100. Memory at segment offset M therefore comes from file offset
  110. 512 + (M - 0100h)
  111. which is M + 100h for every M in the loaded region. Getting this wrong
  112. is silent: the image is built, qemu boots it, and the descriptor simply
  113. sits at the wrong place. t29_readln then read an INLEN of 0 - the
  114. descriptor was never where the shim looks - so AH=08h reported end of
  115. input on the very first call and readln waited forever for a line
  116. terminator that could not arrive. So the conversion is in one named
  117. function rather than open-coded, and the two constants that define it are
  118. checked against each other above.
  119. """
  120. return mem_off - LOAD_OFF + 512
  121. def build_floppy(boot, com, stdin=b"", load_seg=LOAD_SEG, inlen_off=INLEN_OFF, inbuf_off=INBUF_OFF):
  122. """Assemble the 1.44M image: boot sector, then the .COM, then the input."""
  123. com_max = inlen_off - LOAD_OFF
  124. if len(com) > com_max:
  125. sys.exit("the .COM is %d bytes and the boot sector reads only %d"
  126. % (len(com), com_max))
  127. if len(stdin) > INBUF_MAX:
  128. sys.exit("%d bytes of input, at most %d fit" % (len(stdin), INBUF_MAX))
  129. img = bytearray(FLOPPY_BYTES)
  130. img[0:512] = boot
  131. # The .COM goes at disk sector 2, which is where bootcom.s reads from.
  132. # to_file(LOAD_OFF) == 512, so this agrees with the mapping above.
  133. img[512:512 + len(com)] = com
  134. inlen_f, inbuf_f = to_file(inlen_off), to_file(inbuf_off)
  135. img[inlen_f:inlen_f + 2] = len(stdin).to_bytes(2, "little")
  136. img[inbuf_f:inbuf_f + len(stdin)] = stdin
  137. return bytes(img)
  138. def run_qemu(floppy):
  139. """Boot the image. Return (output bytes, exit code or None, note)."""
  140. with tempfile.TemporaryDirectory() as td:
  141. ser = os.path.join(td, "serial.bin")
  142. cmd = [QEMU,
  143. "-drive", "file=%s,format=raw,if=floppy,index=0" % floppy,
  144. "-chardev", "file,id=s0,path=" + ser,
  145. "-serial", "chardev:s0",
  146. "-device", "isa-debug-exit,iobase=0x501,iosize=1",
  147. "-display", "none",
  148. "-no-reboot"]
  149. try:
  150. r = subprocess.run(cmd, capture_output=True, timeout=TIMEOUT)
  151. except subprocess.TimeoutExpired:
  152. # Whatever reached the serial port is still evidence, so keep it
  153. # and report the hang rather than throwing the output away.
  154. out = open(ser, "rb").read() if os.path.exists(ser) else b""
  155. return out, None, "the machine never halted (timeout %ds)" % TIMEOUT
  156. out = open(ser, "rb").read() if os.path.exists(ser) else b""
  157. # qemu's OWN code, which is the only record of whether the program reached
  158. # the isa-debug-exit port. It used to be dropped and 0 returned, which
  159. # exit_code_of then had to read as "unknown" - so a program that ran to
  160. # completion and one that hung were indistinguishable here, and every
  161. # fixture reported exit=None. Passing the real code through is what makes
  162. # "it terminated" an assertion rather than an assumption.
  163. return out, r.returncode, ""
  164. def exit_code_of(rc):
  165. """The DOS exit code the program passed to INT 21h AH=4Ch, or None."""
  166. if rc is None or rc == 0 or (rc - EXIT_BASE) % 2 != 0:
  167. return None
  168. return (rc - EXIT_BASE) // 2
  169. def ensure_comtest():
  170. """Return the path to comtest, rebuilding it if any source is newer.
  171. Timestamp-checked rather than assumed, because this project has already
  172. lost a session to a stale `comtest` disagreeing with a freshly built
  173. `compiletest`. See the mtime comparison in tests/uitest.py's siblings.
  174. """
  175. exe = os.path.join(SHELL, "comtest")
  176. # The freshness list must be the files actually LINKED - the .o files - not
  177. # the .mod files they came from. Watching the sources instead left a real
  178. # hole: `make` relinks Compiler.o from an edited Compiler.mod, and if that
  179. # happened after comtest was last built, comtest was older than every
  180. # .mod and so looked current while containing the old compiler. The
  181. # failure mode is the worst kind: the .COM under test is generated by the
  182. # stale compiler, so a fix is invisible and a test that should have gone
  183. # red stays green. The .mod files are kept in the list anyway, so editing a
  184. # source without running make also forces a relink.
  185. objs = ["TextBuf.o", "Posix.o", "Compiler.o", "Runtime.o", "Linker.o"]
  186. newer = objs + ["Compiler.mod", "Runtime.mod", "Linker.mod", "TextBuf.mod",
  187. "Posix.c", os.path.join("tests", "ComTest.mod")]
  188. if os.path.exists(exe):
  189. t = os.path.getmtime(exe)
  190. if all(os.path.exists(os.path.join(SHELL, f))
  191. and os.path.getmtime(os.path.join(SHELL, f)) <= t
  192. for f in newer):
  193. return exe
  194. lst = os.path.join(SHELL, "tests", "ct.lst")
  195. link = ([os.path.join(SHELL, "tests", "ComTest.mod")]
  196. + [os.path.join(SHELL, o) for o in objs])
  197. subprocess.run([GM2, "-fiso", "-fgen-module-list=" + lst, "-o", "/dev/null"]
  198. + link, capture_output=True, cwd=SHELL)
  199. r = subprocess.run([GM2, "-fiso", "-fuse-list=" + lst, "-o", exe] + link,
  200. capture_output=True, cwd=SHELL)
  201. if r.returncode != 0:
  202. sys.exit("linking comtest failed:\n" + r.stderr.decode(errors="replace"))
  203. return exe
  204. def emit_com(exe, pas, workdir):
  205. """Compile one .pas to workdir/NAME.COM. Return (path or None, output)."""
  206. # ComTest reads paths from stdin and writes the .COM into its own cwd,
  207. # named after the fixture's base name. So cwd is the workdir.
  208. r = subprocess.run([exe], input=pas.encode() + b"\n",
  209. capture_output=True, cwd=workdir)
  210. com = os.path.join(workdir,
  211. os.path.basename(pas)[:-4] + ".COM")
  212. if not os.path.exists(com):
  213. return None, r.stdout.decode(errors="replace").strip()
  214. return com, r.stdout.decode(errors="replace").strip()
  215. def visible_output(raw):
  216. """Turn a repr-able byte string into something a terminal shows."""
  217. return raw.decode("latin-1").replace("\r", "\\r").replace("\n", "\\n")
  218. def cases():
  219. """Every fixture that has a .out file, with its optional input and exit code.
  220. A fixture normally ends at progend, which exits through INT 21h AH=4Ch with
  221. AL=0, so the expected code is 0. But a program can also leave through the
  222. RUNTIME ERROR path - strcat exits 16 when a concatenation would overflow
  223. 255 - and 16 is not 0. Asserting the expected code is what makes "the
  224. program stopped because it overflowed" a checked claim instead of a number
  225. a human reads off the PASS line; without it, a program that stopped for the
  226. wrong reason with the same bytes on the wire would still pass.
  227. A fixture that should exit non-zero says so in a `.exit' file next to its
  228. `.out', holding the decimal code and nothing else.
  229. """
  230. fx = os.path.join(HERE, "fixtures")
  231. for name in sorted(os.listdir(fx)):
  232. if not name.endswith(".out"):
  233. continue
  234. stem = name[:-4]
  235. stdin = b""
  236. inpath = os.path.join(fx, stem + ".in")
  237. if os.path.exists(inpath):
  238. stdin = open(inpath, "rb").read()
  239. expath = os.path.join(fx, stem + ".exit")
  240. want_exit = 0
  241. if os.path.exists(expath):
  242. want_exit = int(open(expath).read().strip())
  243. yield {"name": stem,
  244. "out": os.path.join(fx, name),
  245. "in": inpath if os.path.exists(inpath) else None,
  246. "stdin": stdin,
  247. "exit": want_exit}
  248. def main(argv):
  249. if "--list" in argv:
  250. for c in cases():
  251. print(c["name"])
  252. return 0
  253. show = "--show" in argv
  254. rebless = "--rebless" in argv
  255. names = [a for a in argv if not a.startswith("-")]
  256. all_cases = list(cases())
  257. if names:
  258. sel = [c for c in all_cases if c["name"] in names]
  259. missing = set(names) - {c["name"] for c in all_cases}
  260. if missing:
  261. sys.exit("no such fixture: " + ", ".join(sorted(missing)))
  262. else:
  263. sel = all_cases
  264. boot = build_boot_sector()
  265. exe = ensure_comtest()
  266. workdir = tempfile.mkdtemp(prefix="tpexec-")
  267. passed = failed = blessed = 0
  268. for c in sel:
  269. pas = os.path.join(HERE, "fixtures", c["name"] + ".pas")
  270. com, log = emit_com(exe, pas, workdir)
  271. if com is None:
  272. print(" %-22s FAIL comtest wrote no .COM" % c["name"])
  273. if log:
  274. print(" %s" % log)
  275. failed += 1
  276. continue
  277. data = open(com, "rb").read()
  278. img = os.path.join(workdir, c["name"] + ".img")
  279. open(img, "wb").write(build_floppy(boot, data, c["stdin"]))
  280. got, rc, note = run_qemu(img)
  281. want = open(c["out"], "rb").read()
  282. if show:
  283. print(" %-22s exit=%s %s" % (c["name"], exit_code_of(rc),
  284. visible_output(got)))
  285. continue
  286. if rc is None:
  287. print(" %-22s FAIL %s" % (c["name"], note))
  288. print(" output so far: %s" % visible_output(got))
  289. failed += 1
  290. continue
  291. code = exit_code_of(rc)
  292. if code is None:
  293. print(" %-22s FAIL qemu rc=%d, which no .COM exit can produce "
  294. "(the program never called INT 21h AH=4Ch)" % (c["name"], rc))
  295. failed += 1
  296. continue
  297. if got != want:
  298. if rebless:
  299. open(c["out"], "wb").write(got)
  300. print(" %-22s REBLESSED %s" % (c["name"], visible_output(got)))
  301. blessed += 1
  302. continue
  303. print(" %-22s FAIL" % c["name"])
  304. print(" want %s" % visible_output(want))
  305. print(" got %s" % visible_output(got))
  306. failed += 1
  307. continue
  308. if code != c["exit"]:
  309. print(" %-22s FAIL exit %d, but the program is expected to exit "
  310. "%d" % (c["name"], code, c["exit"]))
  311. failed += 1
  312. continue
  313. print(" %-22s PASS %d bytes, exit %d"
  314. % (c["name"], len(got), code))
  315. passed += 1
  316. if show:
  317. return 0
  318. print()
  319. if rebless and blessed:
  320. print("REBLESSED %d expectation file(s). Check the diff: a .out that "
  321. "changed is a claim that was wrong, or a program that is."
  322. % blessed)
  323. print("execution: %d passed, %d failed (of %d)"
  324. % (passed, failed, len(sel)))
  325. return 1 if failed else 0
  326. if __name__ == "__main__":
  327. sys.exit(main(sys.argv[1:]))