run_com_tests.sh 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. #!/bin/bash
  2. # Build and run the .COM linker harness (tests/ComTest.mod), then verify every
  3. # .COM it produced with an INDEPENDENT checker.
  4. #
  5. # The independent pass matters: ComTest computes the expectations from the same
  6. # Compiler state it is testing, so a bug in the compiler would be invisible to
  7. # it. The Python pass re-derives what the file must contain - the runtime's
  8. # first bytes, a zero gap, a size that covers the data area - from the
  9. # constants only, and cross-checks.
  10. set -u
  11. D=/home/eric/Projets/Projets-Modula2/MyWork/TP3-comp/shell
  12. GM2=/home/eric/bin/Modula2/Gm2/bin/gm2
  13. cd "$D" || exit 9
  14. FLAGS="-fiso"
  15. # --check-only DIR -- skip the build and the link, and run only the
  16. # independent Python checker over the .COM files already in DIR (plus a
  17. # hand-written raw.txt in the same shape the linker emits).
  18. #
  19. # This exists for non-vacuity, and it is the only reason to make it. The
  20. # checker normally runs over a scratch directory that the EXIT trap deletes,
  21. # so there is no way to hand it a DELIBERATELY WRONG .COM and see whether it
  22. # notices. A check that has only ever been shown the truth is not a check:
  23. # it could be reporting the truth about every file because it says nothing at
  24. # all. tests/nonvacuity.sh uses this to feed it a corrupted image and
  25. # require the named assertion to go red.
  26. CHECK_ONLY=""
  27. if [ "${1:-}" = "--check-only" ]; then
  28. CHECK_ONLY=${2:-}
  29. shift 2
  30. fi
  31. echo "== support modules =="
  32. if [ -n "$CHECK_ONLY" ]; then
  33. echo "check-only mode: not rebuilding, not linking"
  34. OUT="$CHECK_ONLY"
  35. [ -d "$OUT" ] || { echo "RESULT: FAIL (no such directory: $OUT)"; exit 1; }
  36. else
  37. [ -f Posix.o ] || cc -c Posix.c || exit 1
  38. for m in TextBuf Compiler Runtime Linker; do
  39. $GM2 $FLAGS -c $m.mod >/tmp/cm_c_$m 2>&1 \
  40. || { echo "COMPILE_FAIL $m"; grep -m5 "error:" /tmp/cm_c_$m; exit 1; }
  41. done
  42. $GM2 $FLAGS -c tests/ComTest.mod >/tmp/cm_c_ComTest 2>&1 \
  43. || { echo "COMPILE_FAIL ComTest"; grep -m5 "error:" /tmp/cm_c_ComTest; exit 1; }
  44. rm -f tests/ct.lst comtest
  45. $GM2 $FLAGS -fgen-module-list=tests/ct.lst -o /dev/null \
  46. tests/ComTest.mod TextBuf.o Posix.o Compiler.o Runtime.o Linker.o \
  47. >/tmp/cm_p1 2>&1
  48. p1=$?
  49. $GM2 $FLAGS -fuse-list=tests/ct.lst -o comtest \
  50. tests/ComTest.mod TextBuf.o Posix.o Compiler.o Runtime.o Linker.o \
  51. >/tmp/cm_p2 2>&1
  52. p2=$?
  53. if [ $p2 -ne 0 ]; then
  54. echo "LINK_FAIL p1_rc=$p1 p2_rc=$p2"
  55. grep -E "error:|undefined" /tmp/cm_p2 | head -10
  56. exit 1
  57. fi
  58. echo "comtest built (p1_rc=$p1, phase 1 rc=1 is the expected rollup)"
  59. # .COM files are written beside the harness, so run it in a scratch dir
  60. OUT=$(mktemp -d) || exit 9
  61. # TP_COM_KEEP=1 leaves the scratch dir behind, for tests/nonvacuity.sh to
  62. # corrupt a copy of a real image and hand it back through --check-only. The
  63. # alternative - rebuilding the whole toolchain inside the non-vacuity script
  64. # to produce one throwaway byte - is slow for no benefit, and the point of
  65. # the case is the CHECKER's sensitivity, not the compiler's.
  66. if [ "${TP_COM_KEEP:-0}" = "1" ]; then
  67. echo "TP_COM_KEEP=1: images left in $OUT"
  68. else
  69. trap 'rm -rf "$OUT"' EXIT
  70. fi
  71. cd "$OUT" || exit 9
  72. ls "$D"/tests/fixtures/*.pas | "$D"/comtest > "$OUT/raw.txt" 2>&1
  73. sed 's/^.*fixtures\///' "$OUT/raw.txt"
  74. echo "----------------------------------------------------------------"
  75. nok=$(grep -c " OK com=" "$OUT/raw.txt")
  76. nerr=$(grep -c " ERROR " "$OUT/raw.txt")
  77. nbad=$(grep -cE "WRITE_COM_FAILED|CANNOT" "$OUT/raw.txt")
  78. echo "linked: $nok .COM files, $nerr fixtures rejected at compile time, $nbad harness failures"
  79. if [ "$nbad" -ne 0 ]; then
  80. echo "RESULT: FAIL (harness could not link every compiling fixture)"
  81. exit 1
  82. fi
  83. fi
  84. # ---- independent verification of the bytes on disk ------------------------
  85. python3 - "$OUT" <<'PYEOF'
  86. import sys, os, re, glob
  87. out = sys.argv[1]
  88. # ENT_SZ and HDR_SZ are the layout constants, and they are RESTATED here on
  89. # purpose: the checker must not ask the code under test what the answer is.
  90. #
  91. # RT_SZ is different, and it is NOT restated. It used to be a literal, and it
  92. # was WRONG - 391, against a runtime of 432 bytes - so the header was read at
  93. # offset 394 instead of 435 and every one of the 30 .COM files "failed" on a
  94. # header full of code bytes. A duplicated constant that has silently drifted
  95. # is not an independent check; it is a second source of truth that lies, and
  96. # it lies in the direction of looking like the compiler is broken.
  97. #
  98. # So the runtime's size is MEASURED, from the .COM itself: the runtime is the
  99. # region between the entry jump and the program header, and the header is
  100. # found by its own signature rather than by an assumed offset (hdrFlag = 1,
  101. # with the code END and the data base where the layout says they are). If
  102. # the runtime ever changes size, this follows automatically; if the LAYOUT
  103. # changes, the header stops being found and the checker says so instead of
  104. # quietly measuring the wrong thing.
  105. #
  106. # The measurement is still independent of the compiler - it reads the emitted
  107. # file, not a Modula-2 variable - so it cannot be satisfied by the code under
  108. # test agreeing with itself. tests/check_runtime.py pins the size explicitly,
  109. # which is where a deliberate size change should be noticed.
  110. RT_SZ = None # measured per .COM by find_header, below
  111. # The image starts with a three-byte JMP at offset 0 -- see the layout comment
  112. # in Compiler.Inittur. It has to be there: a .COM is entered at file offset
  113. # 0, and until the jump existed this checker ASSERTED that the runtime was at
  114. # offset 0, which is precisely the bug. A checker that pins a wrong invariant
  115. # is worse than no checker, because it makes the wrong thing look tested.
  116. ENT_SZ = 3 # E9 lo hi
  117. HDR_SZ = 16 # 5 header words + 3 buffer words, see Compiler
  118. # RTSZ (image offset of the program header), PROLOG (RTSZ + HDR_SZ, where the
  119. # entry jump must land) and DATAB (RTSZ + 1000h, the compiler's data base) are
  120. # all DERIVED PER FILE by find_header below, not written down here. They used
  121. # to be module constants built on the restated RT_SZ, which is the bug this
  122. # whole block exists to remove: every one of them was wrong by 41 bytes, and
  123. # a checker that is consistently wrong in a simple direction does not fail -
  124. # it re-reports the same false 30 failures, in which the real ones hide.
  125. # The load bias: DOS puts a .COM's first byte at CS:0100, and CS = DS, so an
  126. # image offset K lives at DS:(K + 0100h). Every ABSOLUTE address in the
  127. # image must carry it; relative encodings (the entry jump, every CALL) must
  128. # not, since both operands shift together. Restated here so that the header
  129. # checks below compare against the addresses the program will actually use,
  130. # and so that the +0100h in them is a decision this checker made rather than
  131. # an accident of the compiler's. See Runtime.LoadBias.
  132. #
  133. # This is the THIRD bias of the same family in this file, and the subtlest:
  134. # the entry jump (a jump that landed on the end of the code), the RT_Entry
  135. # offsets (CALLs that landed inside a neighbouring runtime entry) and this
  136. # one (absolute addresses that landed 0100h low, inside the runtime) all
  137. # produce a program that STARTS, RUNS and PRINTS something. Only running it
  138. # finds this one; the byte checks are all satisfied by an address that is
  139. # consistently 0100h wrong.
  140. LOAD_BIAS = 0x100
  141. # initmem's prologue, which is the runtime's only reader of the program
  142. # header. The displacements +4 and +6 below are the whole point of this
  143. # constant: the header word checks further down read hdrDS at +4 and hdrHeap
  144. # at +6, and initmem has to read the SAME two words or it clears the wrong
  145. # range. It used to read +8 (hdrMax, which the compiler patches to 0), so it
  146. # zeroed nothing at all, and nothing here noticed -- the emitted loop was
  147. # perfectly well formed, it just never ran. Asserting the bytes and the
  148. # header offsets together is what closes that gap.
  149. #
  150. # It is the FIRST ELEVEN BYTES OF THE RUNTIME, so it sits at image offset
  151. # ENT_SZ, not 0.
  152. HEAD = '8B F0 8B 54 04 8B 4C 06' # 11 bytes of initmem, see below
  153. HDR_DS_WORD = 4 # header word holding the data base
  154. HDR_HEAP_WORD = 6 # header word holding the data end
  155. # Byte 4 of HEAD is the displacement of initmem's MOV DX,[SI+?], and byte 7
  156. # the displacement of its MOV CX,[SI+?]. The checks below read the header
  157. # words at HDR_DS_WORD and HDR_HEAP_WORD, so tying those two displacements to
  158. # the same two constants is what makes the runtime and the compiler agree by
  159. # construction rather than by coincidence.
  160. assert [int(HEAD.split()[4], 16), int(HEAD.split()[7], 16)] == \
  161. [HDR_DS_WORD, HDR_HEAP_WORD], \
  162. 'initmem no longer reads the two header words this checker verifies'
  163. raw = open(os.path.join(out, 'raw.txt')).read()
  164. rows = re.findall(r'(\S+\.pas)\s+OK\s+com=(\d+)\s+image=(\d+)\s+data=(\d+)\s+nonzeroInGap=(\d+)', raw)
  165. if not rows:
  166. print('RESULT: FAIL (no linked fixtures found in output)')
  167. sys.exit(1)
  168. def find_header(d):
  169. """Locate the program header by its own signature. Returns its image
  170. offset, or None.
  171. The header is eight words at image offset ENT_SZ + rtSz, and the layout
  172. says what they are (offsets here are BYTES into the header, which is why
  173. HDR_DS_WORD is 4 and not 2 - the words are 2 bytes each and 1-based by
  174. two, not by one):
  175. +0 1 hdrFlag, always 1
  176. +2 code end + bias hdrCS
  177. +4 data base + bias hdrDS, where data base = hdrOff + 1000h
  178. +6 data end + bias hdrHeap, which is hdrDS + dataBytes
  179. hdrDS ties the header to its OWN offset, so the offset is recoverable from
  180. the file without assuming a runtime size: hdrOff = hdrDS - 1000h - bias.
  181. A candidate is accepted only if hdrFlag is 1, hdrDS satisfies that
  182. equation, hdrHeap is above hdrDS (a heap below its own base is not a
  183. layout, it is a coincidence), and the runtime's known first bytes are
  184. where they belong. initmem is the ONLY code in the image that reads the
  185. header, so its bytes cannot themselves move: they are at ENT_SZ always.
  186. Measuring beats restating the size, and it is not a loss of independence:
  187. it reads the EMITTED FILE, so the compiler cannot satisfy it by agreeing
  188. with itself. tests/check_runtime.py is where the runtime's size is pinned
  189. deliberately, and this checker reports the size it measured on every run,
  190. so a change there is visible rather than absorbed.
  191. """
  192. head = bytes(int(x, 16) for x in HEAD.split())
  193. if d[ENT_SZ:ENT_SZ + len(head)] != head:
  194. return None # no runtime: nothing to measure
  195. for off in range(ENT_SZ, len(d) - HDR_SZ + 1):
  196. w = (lambda b: int.from_bytes(d[off + b:off + b + 2], 'little'))
  197. if w(0) != 1: # hdrFlag
  198. continue
  199. if w(HDR_DS_WORD) != off + 0x1000 + LOAD_BIAS: # hdrDS
  200. continue
  201. if w(HDR_HEAP_WORD) <= w(HDR_DS_WORD): # hdrHeap
  202. continue
  203. if w(2) - LOAD_BIAS < off + HDR_SZ: # hdrCS
  204. continue
  205. return off
  206. return None
  207. bad = 0
  208. last_rt = None
  209. for name, com, image, data, nzg in rows:
  210. com, image, data, nzg = int(com), int(image), int(data), int(nzg)
  211. # ComTest writes the .COM by BASENAME beside itself (it cannot graft a
  212. # directory onto a source path), so the checker must look for the bare
  213. # name, not the full source path the fixture was read from.
  214. path = os.path.join(out, os.path.basename(name)[:-4] + '.COM')
  215. errs = []
  216. if not os.path.exists(path):
  217. errs.append('no .COM file')
  218. d = b''
  219. else:
  220. d = open(path, 'rb').read()
  221. # Everything below is expressed in terms of where the header actually is,
  222. # measured from this file, rather than where a literal says it should be.
  223. hdrOff = find_header(d)
  224. if hdrOff is None:
  225. errs.append('no program header found: the layout this checker knows '
  226. 'how to look for is not the one in the file')
  227. RTSZ, PROLOG, DATAB = ENT_SZ, ENT_SZ + HDR_SZ, ENT_SZ + 0x1000
  228. else:
  229. RTSZ = hdrOff
  230. PROLOG = hdrOff + HDR_SZ
  231. DATAB = hdrOff + 0x1000
  232. if RTSZ != last_rt:
  233. last_rt = RTSZ
  234. print(' measured runtime size: %d bytes (header at image offset '
  235. '%d)' % (RTSZ - ENT_SZ, RTSZ))
  236. # The entry jump. This is the one assertion in the whole project that can
  237. # see where execution STARTS, because it is the only one that cares. It
  238. # has caught THREE real bugs, all in the same three bytes, and all of them
  239. # invisible to every other check here:
  240. #
  241. # 1. no jump at all, so a .COM began by executing the runtime's initmem
  242. # with whatever the loader left in AX;
  243. # 2. a jump to `pc`, one byte past the last instruction, into the
  244. # zero-filled code/data gap, where the CPU slides through
  245. # `ADD [BX+SI],AL` until it faults;
  246. # 3. a jump to RTSZ, which is the program HEADER - sixteen bytes of DATA
  247. # that the CPU then decodes as instructions. This one is the reason
  248. # the target is pinned to PROLOG and not to RTSZ: whether it works
  249. # depends entirely on how those sixteen bytes happen to decode, so
  250. # writeln('hi') ran correctly by sliding through them while t07 hung
  251. # on a LOCK-prefixed ADD with a displacement crossing a page. The
  252. # correct target is the first instruction, and there is no reason for
  253. # a checker to accept a range.
  254. if len(d) >= ENT_SZ:
  255. if d[0] != 0xE9:
  256. errs.append('byte 0 is %02X, not the E9 of the entry jump' % d[0])
  257. # The jump's displacement is measured from the end of the jump.
  258. want_rel = PROLOG - ENT_SZ
  259. got_rel = int.from_bytes(d[1:ENT_SZ], 'little')
  260. if got_rel != want_rel:
  261. errs.append('entry jump rel16=%d, want %d; it lands on image '
  262. 'offset %d, want %d (the first instruction, %d bytes '
  263. 'past the header - not the header at %d, and not the '
  264. 'end of the code at %d)'
  265. % (got_rel, want_rel, ENT_SZ + got_rel, PROLOG,
  266. HDR_SZ, RTSZ, image))
  267. # The runtime's own first bytes must follow the jump, and the jump must be
  268. # the only thing before them.
  269. if d[ENT_SZ:ENT_SZ + len(HEAD.split())].hex(' ').upper() != HEAD:
  270. errs.append('runtime not at offset %d (bytes there %s, want %s)'
  271. % (ENT_SZ,
  272. d[ENT_SZ:ENT_SZ + len(HEAD.split())].hex(' ').upper(),
  273. HEAD))
  274. if len(d) != com:
  275. errs.append('file is %d bytes, harness said %d' % (len(d), com))
  276. if DATAB + data != len(d):
  277. errs.append('size %d != dataBase+data %d' % (len(d), DATAB + data))
  278. # the gap between the image and the data area must be entirely zero
  279. gap = d[image:DATAB]
  280. if any(gap):
  281. errs.append('%d non-zero bytes in the code/data gap' % sum(1 for b in gap if b))
  282. if nzg != 0:
  283. errs.append('harness itself reported %d non-zero gap bytes' % nzg)
  284. # the program must start exactly where the runtime ends
  285. if image < RTSZ:
  286. errs.append('image %d shorter than the runtime %d' % (image, RTSZ))
  287. # the program header sits at offset rtSz, and its words must describe the
  288. # image that is actually in the file
  289. if len(d) > RTSZ + 8:
  290. hdr = d[RTSZ:RTSZ+16]
  291. flag = int.from_bytes(hdr[0:2], 'little')
  292. cs = int.from_bytes(hdr[2:4], 'little')
  293. ds = int.from_bytes(hdr[HDR_DS_WORD:HDR_DS_WORD+2], 'little')
  294. heap = int.from_bytes(hdr[HDR_HEAP_WORD:HDR_HEAP_WORD+2], 'little')
  295. if flag != 1:
  296. errs.append('hdrFlag=%d' % flag)
  297. # hdrCS is the end of the code, as a SEGMENT offset like every other
  298. # offset in the header, so the load bias comes off before comparing it
  299. # with the harness's `image` (= rtSz + code, already an image offset).
  300. # Adding RTSZ here would count the runtime twice.
  301. if cs - LOAD_BIAS != image:
  302. errs.append('hdrCS=%d, want %d (end of image, as a segment '
  303. 'offset)' % (cs, image + LOAD_BIAS))
  304. if ds != DATAB + LOAD_BIAS:
  305. errs.append('hdrDS=%d, want %d (= data base %d + load bias %d)'
  306. % (ds, DATAB + LOAD_BIAS, DATAB, LOAD_BIAS))
  307. if heap != DATAB + data + LOAD_BIAS:
  308. errs.append('hdrHeap=%d, want %d (= data base %d + %d + load bias %d)'
  309. % (heap, DATAB + data + LOAD_BIAS, DATAB, data,
  310. LOAD_BIAS))
  311. # initmem must read hdrDS and hdrHeap, not some other pair of header
  312. # words. (It read +8, hdrMax, which the compiler patches to 0, so
  313. # the range it cleared was empty and every global kept whatever the
  314. # loader left in it.)
  315. if [d[ENT_SZ + 4], d[ENT_SZ + 7]] != [HDR_DS_WORD, HDR_HEAP_WORD]:
  316. errs.append('initmem reads header words +%d/+%d, but the data '
  317. 'base and data end are at +%d/+%d'
  318. % (d[ENT_SZ + 4], d[ENT_SZ + 7],
  319. HDR_DS_WORD, HDR_HEAP_WORD))
  320. if errs:
  321. bad += 1
  322. print(' %-24s FAIL %s' % (os.path.basename(name)[:-4], '; '.join(errs)))
  323. else:
  324. print(' %-24s PASS %d bytes' % (os.path.basename(name)[:-4], com))
  325. print('----------------------------------------------------------------')
  326. print('independent .COM check: %d checked, %d failed' % (len(rows), bad))
  327. sys.exit(1 if bad else 0)
  328. PYEOF
  329. rc=$?
  330. [ "$rc" -eq 0 ] || exit 1
  331. echo "RESULT: ALL PASS"