run_com_exec.py 14 KB

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