run_com_exec.py 16 KB

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