// Line Discipline, checked against a real kernel and then broken on purpose. // // The oracle is not another implementation. It is Linux: scripts/line- // discipline-pty.py opens a pseudo-terminal, writes the bytes a key sends into // the master side and records what read() on the slave side returns, in order. // Its transcripts are committed as tests/line-discipline-pty.json with the // kernel version and the full termios state. This test compares the page's // engine with every transcript, and where a pty can be opened it runs the // kernel again, wider, and compares with that as well. It says which ran. // // A second route, written here from POSIX's wording rather than from the // engine, is compared with the same transcripts, so a disagreement says which // of the three moved. import { readFileSync, existsSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..'); let p = 0; let f = 0; const check = (label, ok, detail = '') => { if (ok) { p += 1; console.log(` ok ${label}${detail ? ` [${detail}]` : ''}`); } else { f += 1; console.error(` FAIL ${label}${detail ? `\n ${detail}` : ''}`); } }; const note = (s) => console.log(` -- ${s}`); const ENGINE = join(ROOT, 'site/js/line-discipline.js'); function engine(src = readFileSync(ENGINE, 'utf8')) { const window = {}; new Function('window', src)(window); return window.LineDiscipline; } const L = engine(); const FIXTURE = JSON.parse(readFileSync(join(HERE, 'line-discipline-pty.json'), 'utf8')); /* ---- the transcripts, decoded --------------------------------------------- */ const fromHex = (h) => [...Buffer.from(h, 'hex')]; function decode(doc) { const out = []; for (const g of doc.groups) { const erase = doc.settings[g.setting].VERASE; for (const c of g.cases) { const [keys, rest] = c.split(':'); const [reads, pending] = rest.split('|'); out.push({ setting: g.setting, erase: erase === null ? undefined : erase, size: g.read, keys: fromHex(keys), reads: reads ? reads.split('.').slice(0, -1).map(fromHex) : [], pending: fromHex(pending), }); } } return out; } const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]); const sameReads = (a, b) => a.length === b.length && a.every((x, i) => same(x, b[i])); const shown = (bytes) => JSON.stringify(Buffer.from(bytes).toString('latin1')); /* ---- the second route ------------------------------------------------------ * * Written from POSIX 11.1.6 and 11.1.9 as a string transformation, sharing * nothing with the engine but the byte values. Split the input at the line * delimiters; in each piece the text after the last KILL is what survives a * kill; then each ERASE removes the byte before it, and an ERASE with nothing * before it does nothing. An EOF ends a piece and is dropped; a newline ends a * piece and is kept. read() hands out a piece in slices of at most `size`, and * an empty piece once, as zero bytes. */ function secondRoute(keys, erase = 0x7f, size = 4096) { const KILL = 0x15, EOF = 0x04, NL = 0x0a, CR = 0x0d; const pieces = []; let cur = []; for (const raw of keys) { const b = raw === CR ? NL : raw; if (b === EOF) { pieces.push(cur); cur = []; } else if (b === NL && b !== erase) { cur.push(b); pieces.push(cur); cur = []; } else cur.push(b); } const edit = (piece) => { let s = piece; const lastKill = s.lastIndexOf(KILL); if (lastKill >= 0) s = s.slice(lastKill + 1); const kept = []; for (const b of s) { if (b === erase) kept.pop(); else kept.push(b); } return kept; }; const lines = pieces.map(edit); const reads = []; for (const line of lines) { if (!line.length) { reads.push([]); continue; } for (let i = 0; i < line.length; i += size) reads.push(line.slice(i, i + size)); } return { reads, pending: edit(cur) }; } /* ---- 1. the engine's own claims ------------------------------------------- */ { const rows = L.verify(); check('the page makes at least fourteen checks at load', rows.length >= 14, String(rows.length)); rows.forEach((r) => check(`load-time: ${r.claim.slice(0, 70)}`, r.ok && !r.untested, r.detail)); } /* ---- 2. what the transcripts were made under ------------------------------ */ { const k = FIXTURE.kernel || {}; check('the transcripts record the kernel they came from', k.sysname === 'Linux' && /^\d+\.\d+/.test(k.release || ''), `${k.sysname} ${k.release}`); check('and the date they were captured', /^\d{4}-\d{2}-\d{2}$/.test(FIXTURE.captured || ''), FIXTURE.captured); const t = FIXTURE.termios_of_a_new_pty; check('and a new pty is in canonical mode with CR mapped to NL', t.set.lflag.includes('ICANON') && t.set.iflag.includes('ICRNL'), `${t.set.lflag.join(' ')} / ${t.set.iflag.join(' ')}`); check("the engine's ERASE, KILL and EOF are the kernel's defaults", L.DEFAULTS.erase === t.cc.VERASE && L.DEFAULTS.kill === t.cc.VKILL && L.DEFAULTS.eof === t.cc.VEOF, `kernel ${t.cc.VERASE}/${t.cc.VKILL}/${t.cc.VEOF}, engine ${L.DEFAULTS.erase}/${L.DEFAULTS.kill}/${L.DEFAULTS.eof}`); check('and the extra delimiters the sentinel borrows are off by default', t.cc.VEOL === 0 && t.cc.VEOL2 === 0); // The refusal list is derived from the kernel's special characters, not // typed: every enabled special the engine does not model must be refused, // except VDISCARD, which Linux lists and does not implement. const flagFor = { VINTR: 'ISIG', VQUIT: 'ISIG', VSUSP: 'ISIG', VSTART: 'IXON', VSTOP: 'IXON', VWERASE: 'IEXTEN', VREPRINT: 'IEXTEN', VLNEXT: 'IEXTEN', VDISCARD: 'IEXTEN' }; const on = (flag) => t.set.lflag.includes(flag) || t.set.iflag.includes(flag); const specials = Object.entries(flagFor) .filter(([name, flag]) => on(flag) && t.cc[name] !== 0) .map(([name]) => [name, t.cc[name]]); const shouldRefuse = specials.filter(([name]) => name !== 'VDISCARD').map(([, v]) => v).sort((a, b) => a - b); const refused = Object.keys(L.NOT_MODELLED).map(Number).filter((v) => v !== 0).sort((a, b) => a - b); check('the engine refuses exactly the special characters it does not model', same(shouldRefuse, refused) && shouldRefuse.length === 8, `kernel ${shouldRefuse.join(',')}; engine ${refused.join(',')}`); check('and treats VDISCARD as ordinary, as the kernel does', L.classify(t.cc.VDISCARD).kind === 'ordinary' && t.cc.VDISCARD === 15); check('every refused byte is refused, not run', refused.every((b) => L.run([0x61, b, 0x0a]).refused !== null)); // Linux acts on INTR, QUIT, SUSP, START and STOP before it looks at ERASE, // and an ERASE of NUL is disabled, so an ERASE set to any refused byte is // refused too (red team 2026-10-08: ERASE=^C gave "ac\n", the kernel "c\n"). check('an ERASE set to a byte the engine does not model is refused, not run as an erase', [0, ...refused].every((e) => L.run([0x61, 0x62, e, 0x63, 0x0a], { erase: e }).refused !== null) && L.run([0x61, 0x62, 0x08, 0x63, 0x0a], { erase: 0x08 }).refused === null); } /* ---- 3. the engine against every committed transcript --------------------- */ const CASES = decode(FIXTURE); function compareAll(cases, label) { let n = 0, engineBad = 0, routeBad = 0; const firstBad = []; for (const c of cases) { n += 1; const r = L.run(c.keys, { erase: c.erase }, c.size); const got = r.refused ? null : r.reads.map((x) => x.bytes); if (!got || !sameReads(got, c.reads) || !same(r.pending, c.pending)) { engineBad += 1; if (firstBad.length < 3) firstBad.push(`${shown(c.keys)} read ${c.size}: kernel ${JSON.stringify(c.reads.map((x) => Buffer.from(x).toString('latin1')))}`); } const s = secondRoute(c.keys, c.erase === undefined ? 0x7f : c.erase, c.size); if (!sameReads(s.reads, c.reads) || !same(s.pending, c.pending)) routeBad += 1; } check(`${label}: the engine returns what the kernel returned, every read in order`, n > 0 && engineBad === 0, `${n} cases, ${engineBad} differ ${firstBad.join(' | ')}`); check(`${label}: and so does the second route`, n > 0 && routeBad === 0, `${n} cases, ${routeBad} differ`); return n; } const fixtureCount = compareAll(CASES, 'committed transcripts'); check('the committed transcripts are 14,425 cases', fixtureCount === 14425, String(fixtureCount)); // The transcripts reach the longest line the page lets a reader make. The // limit is read from the page's controls, so the two cannot drift. Red team // 2026-10-08: no case was longer than 12 bytes while the page took 24 keys, and // an erase that failed only on a line of 13 or more passed every check. const SPEC_RAW = readFileSync(join(ROOT, 'machines/line-discipline.json'), 'utf8'); const MAX_KEYS = Number((readFileSync(join(ROOT, 'site/js/line-discipline-ui.js'), 'utf8') .match(/var MAX_KEYS = (\d+);/) || [])[1]); { const longest = Math.max(...CASES.map((c) => c.keys.length)); check('the longest transcript is as long as the most keys the page takes', MAX_KEYS > 0 && longest >= MAX_KEYS, `${longest} bytes, page takes ${MAX_KEYS}`); // And each edit acts on a line of every length up to the longest a reader // can build before pressing it, under each setting. Line lengths are counted // by the second route's rules, not the engine's. const missingLen = []; for (const setting of [0, 1]) { const erase = FIXTURE.settings[setting].VERASE === null ? 0x7f : FIXTURE.settings[setting].VERASE; const seen = { erase: new Set(), kill: new Set(), eof: new Set(), newline: new Set() }; for (const c of CASES.filter((x) => x.setting === setting)) { let line = 0; for (const raw of c.keys) { const b = raw === 0x0d ? 0x0a : raw; if (b === erase) { seen.erase.add(line); line = Math.max(0, line - 1); } else if (b === 0x15) { seen.kill.add(line); line = 0; } else if (b === 0x04) { seen.eof.add(line); line = 0; } else if (b === 0x0a) { seen.newline.add(line); line = 0; } else line += 1; } } for (const [kind, lens] of Object.entries(seen)) { for (let n = 0; n < MAX_KEYS; n += 1) if (!lens.has(n)) missingLen.push(`${setting}/${kind}/${n}`); } } check('under each setting, ERASE, KILL, EOF and newline each meet a line of every length the page can build', missingLen.length === 0 && /longer runs from a fixed seed, as long as the most keys the page takes, with each editing key pressed after a line of every length up to that/ .test(SPEC_RAW), missingLen.length ? `missing ${missingLen.slice(0, 8).join(' ')}` : `0 to ${MAX_KEYS - 1}`); } // The witnesses the roster was admitted on, read straight from the kernel's // transcripts so the expected values are not the engine's. { const kernelSays = (hex, size = 4096, setting = 0) => CASES.find((c) => c.setting === setting && c.size === size && Buffer.from(c.keys).toString('hex') === hex); const w = (hex) => (kernelSays(hex) || { reads: null }).reads; const asText = (reads) => reads && reads.map((x) => Buffer.from(x).toString('latin1')); check('kernel: Ctrl-D on an empty line, read() returns zero bytes', JSON.stringify(asText(w('04'))) === JSON.stringify(['']), JSON.stringify(asText(w('04')))); check('kernel: after hi, read() returns hi', JSON.stringify(asText(w('686904'))) === JSON.stringify(['hi'])); check('kernel: Ctrl-D twice after hi, hi and then zero', JSON.stringify(asText(w('68690404'))) === JSON.stringify(['hi', ''])); check('kernel: newline then Ctrl-D, the line and then zero', JSON.stringify(asText(w('68690a04'))) === JSON.stringify(['hi\n', ''])); check('kernel: three ends of file in a row are three zero-byte reads', JSON.stringify(asText(w('040404'))) === JSON.stringify(['', '', ''])); check('kernel: a backslash does not protect DEL', JSON.stringify(asText(w('615c7f0d'))) === JSON.stringify(['a\n'])); check('kernel: nor Ctrl-D, which releases the backslash with the line', JSON.stringify(asText(w('615c04'))) === JSON.stringify(['a\\'])); check('kernel: Ctrl-O reaches the program as an ordinary byte', JSON.stringify(asText(w('0f0d'))) === JSON.stringify(['\x0f\n'])); check('kernel: an erase cannot reach back past a released line', JSON.stringify(asText(w('6869047f7f0d'))) === JSON.stringify(['hi', '\n'])); const one = kernelSays('686904', 1); check('kernel: read one byte at a time, hi then Ctrl-D is two reads and no zero', one && JSON.stringify(asText(one.reads)) === JSON.stringify(['h', 'i'])); } // What the ledger says about a line too long to keep, from the same run. { const o = FIXTURE.overlong_line; check('kernel: 5,000 letters and a newline come back as one read of 4,096 bytes', o && o.typed === 5000 && o.first_read_bytes === 4096 && o.letters_kept === 4095 && o.ends_with_newline === true, JSON.stringify(o)); // The letters cycle a to z, so which ones were kept is recorded, not only // how many: the first 4,095, and none left waiting after the newline. check('kernel: and the letters kept are the first ones typed, with nothing left waiting', o && o.letters === 'a to z, repeating' && o.kept_is_prefix === true && o.left_waiting === 0, JSON.stringify(o)); } /* ---- 4. the kernel again, here and now, where a pty can be opened --------- */ { // LA_NO_PTY=1 takes the fixture-only branch on purpose, so that branch is // exercised rather than assumed. const ptyAvailable = !process.env.LA_NO_PTY && existsSync('/dev/ptmx') && spawnSync('python3', ['-c', 'import pty, termios; pty.openpty()'], { encoding: 'utf8' }).status === 0; if (!ptyAvailable) { note(`LIVE PTY: NOT RUN, no pty here. The committed transcripts from ${FIXTURE.kernel.release}, ` + `captured ${FIXTURE.captured}, are the oracle for this run.`); } else { const run = spawnSync('python3', [join(ROOT, 'scripts/line-discipline-pty.py'), '--emit', '--wide'], { encoding: 'utf8', maxBuffer: 1 << 28, timeout: 300000 }); check('the live pty sweep ran', run.status === 0, (run.stderr || '').slice(-300)); if (run.status === 0) { const live = JSON.parse(run.stdout); const liveCases = decode(live); const n = compareAll(liveCases, `live kernel ${live.kernel.release}`); check('and it is wider than the committed one, every sequence of five keys included', n > fixtureCount + 59000, `${n} live against ${fixtureCount} committed`); // Same kernel, same answers; another kernel is reported, not hidden. const key = (c) => `${c.setting}/${c.size}/${Buffer.from(c.keys).toString('hex')}`; const liveBy = new Map(liveCases.map((c) => [key(c), c])); let shared = 0, moved = 0; for (const c of CASES) { const l = liveBy.get(key(c)); if (!l) continue; shared += 1; if (!sameReads(l.reads, c.reads) || !same(l.pending, c.pending)) moved += 1; } if (live.kernel.release === FIXTURE.kernel.release) { check('the same kernel gives the committed transcripts again', shared === fixtureCount && moved === 0, `${shared} shared, ${moved} differ`); } else { note(`live kernel ${live.kernel.release} differs from the committed ${FIXTURE.kernel.release}: ` + `${moved} of ${shared} shared cases changed`); } check('and measures the same overlong line', JSON.stringify(live.overlong_line) === JSON.stringify(FIXTURE.overlong_line) || live.kernel.release !== FIXTURE.kernel.release, JSON.stringify(live.overlong_line)); note(`LIVE PTY: RAN on ${live.kernel.sysname} ${live.kernel.release}, ${n} cases.`); } } } /* ---- 5. break it on purpose ------------------------------------------------ */ { const src = readFileSync(ENGINE, 'utf8'); const sample = CASES.filter((c, i) => i % 7 === 0); const cases = [ ['the EOF byte handed to the program', [' released = { data: buf, by: "eof" };', ' buf.push(c.input);\n released = { data: buf, by: "eof" };']], ['an EOF on an empty line that releases nothing', [' } else if (c.kind === "eof") {', ' } else if (c.kind === "eof" && buf.length) {']], ['an erase that reaches into a released line', [' if (buf.length) buf.pop();', ' if (buf.length) buf.pop(); else if (lines.length && lines[lines.length - 1].data.length) lines[lines.length - 1].data.pop();']], ['a carriage return left as it was typed', [' var input = b === CR ? NL : b;', ' var input = b;']], ['a kill that takes one byte', [' } else if (c.kind === "kill") {\n buf = [];', ' } else if (c.kind === "kill") {\n buf.pop();']], ['a read that skips an empty line', [' do {', ' if (data.length) do {']], ['a read that runs two lines together', [' var data = lines[li].data, pos = 0;', ' var data = li + 1 < lines.length && lines[li].by === "newline" ? lines[li].data.concat(lines[++li].data) : lines[li].data, pos = 0;']], // Only at a size the sweep once never asked for, so the "however many // bytes" claim has to look at every size the page offers to see it. ['a two-byte read that runs two lines together', [' var data = lines[li].data, pos = 0;', ' var data = n === 2 && li + 1 < lines.length ? lines[li].data.concat(lines[++li].data) : lines[li].data, pos = 0;']], ]; for (const [label, [from, to]] of cases) { if (!src.includes(from)) { check(`${label} is caught`, false, 'the mutation did not match the engine, so nothing was tested'); continue; } let broken; try { broken = engine(src.replace(from, to)); } catch (e) { check(`${label} is caught`, false, `the mutant did not load: ${e.message}`); continue; } let red; try { red = broken.verify().filter((n) => !n.ok).length; } catch (e) { check(`${label} is caught by the page itself`, false, `verify() threw: ${e.message}`); continue; } let differ = 0; for (const c of sample) { const r = broken.run(c.keys, { erase: c.erase }, c.size); if (r.refused || !sameReads(r.reads.map((x) => x.bytes), c.reads) || !same(r.pending, c.pending)) differ += 1; } check(`${label} is caught by the page itself`, red > 0, `${red} claim(s) go red`); check(`${label} is caught by the kernel's transcripts`, differ > 0, `${differ} of ${sample.length} differ`); } // A leaked byte can sit at the very front of a read. Each of these puts the // byte there and nowhere else, so the page's leak count has to look at // position 0 itself, not only past it. Found by la-mutation-audit: the count // compared indexOf(...) > 0 and every test stayed green. const LEAK = 'and in none of them does the program receive'; const front = [ ['an erase on an empty line kept as a byte', [' if (buf.length) buf.pop();', ' if (buf.length) buf.pop(); else buf.push(c.input);']], ['a kill on an empty line kept as a byte', [' } else if (c.kind === "kill") {\n buf = [];', ' } else if (c.kind === "kill") {\n buf = buf.length ? [] : [c.input];']], ['an EOF on an empty line handed over as a byte', [' released = { data: buf, by: "eof" };', ' released = { data: buf.length ? buf : [c.input], by: "eof" };']], ]; for (const [label, [from, to]] of front) { const leak = src.includes(from) ? engine(src.replace(from, to)).verify() .find((n) => n.claim.startsWith(LEAK)) : null; check(`${label}, at the front of a read, is caught by the leak count`, leak !== null && leak !== undefined && !leak.ok, leak ? leak.detail : 'the mutation did not match'); } // The printable range for display is 0x20 to 0x7e inclusive, at both ends: // a space is shown as a space, not as ^`, and DEL as ^?. const shown = L.show([0x1f, 0x20, 0x41, 0x7e, 0x7f]); check('a space and a tilde are shown as typed, 0x1f and DEL as control characters', shown === '^_ A~^?', JSON.stringify(shown)); check('an unmutated copy passes every one of its own checks', engine(src).verify().every((n) => n.ok)); // And the second route is not merely agreeing with whatever it is shown. const bad = CASES.filter((c) => c.size === 4096).slice(0, 2000); const wrongErase = bad.filter((c) => { const s = secondRoute(c.keys, 0x08, c.size); return !sameReads(s.reads, c.reads) || !same(s.pending, c.pending); }).length; check('the second route, told the wrong ERASE, disagrees with the kernel', wrongErase > 0, `${wrongErase} of ${bad.length}`); } /* ---- 6. the documents say what the page says ------------------------------ * * Sources are read from the archive la-source-audit writes, docs/sources, or * for development from LA_SOURCES_DIR laid out the same way. Neither present * is a failure, never a skip. */ const URLS = { v7tty4: 'https://minnie.tuhs.org/cgi-bin/utree.pl?file=V7/usr/man/man4/tty.4', v7ttyc: 'https://minnie.tuhs.org/cgi-bin/utree.pl?file=V7/usr/sys/dev/tty.c', v7ttyh: 'https://minnie.tuhs.org/cgi-bin/utree.pl?file=V7/usr/sys/h/tty.h', v7bun: 'https://9p.io/7thEdMan/vol1/man0.bun', v6tty4: 'https://minnie.tuhs.org/cgi-bin/utree.pl?file=V6/usr/man/man4/tty.4', v6ttyc: 'https://minnie.tuhs.org/cgi-bin/utree.pl?file=V6/usr/sys/dmr/tty.c', v6intro: 'https://minnie.tuhs.org/cgi-bin/utree.pl?file=V6/usr/man/man0/intro', posix: 'https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap11.html', termios: 'https://man7.org/linux/man-pages/man3/termios.3.html', readline: 'https://www.gnu.org/software/bash/manual/html_node/Commands-For-Text.html', }; const normal = (s) => s.replace(/'/g, "'").replace(/"/g, '"').replace(/</g, '<') .replace(/>/g, '>').replace(/&/g, '&').replace(/\s+/g, ' '); function sourceText(url) { const manifest = join(ROOT, 'docs/sources/manifest.json'); if (existsSync(manifest)) { const e = JSON.parse(readFileSync(manifest, 'utf8')).sources .find((m) => m.url === url && m.status === 'ok'); if (e) return normal(readFileSync(join(ROOT, 'docs/sources', e.file), 'utf8')); } const slug = createHash('sha256').update(url).digest('hex').slice(0, 12); for (const dir of [process.env.LA_SOURCES_DIR, join(ROOT, 'docs/sources/line-discipline')]) { if (dir && existsSync(join(dir, `${slug}.txt`))) return normal(readFileSync(join(dir, `${slug}.txt`), 'utf8')); } return null; } const SRC = Object.fromEntries(Object.entries(URLS).map(([k, u]) => [k, sourceText(u)])); const missing = Object.entries(SRC).filter(([, t]) => t === null).map(([k]) => k); check(missing.length ? 'archive missing: run la-source-audit --archive' : 'every source is archived', missing.length === 0, missing.join(', ')); const has = (k, s) => SRC[k] !== null && SRC[k].includes(s); const SPEC = JSON.parse(readFileSync(join(ROOT, 'machines/line-discipline.json'), 'utf8')); const specText = normal([SPEC.lede, ...SPEC.panels, ...SPEC.ledger.map((l) => `${l.summary}. ${l.body}`)] .join(' ').replace(/<[^>]+>/g, ' ').replace(/“/g, '“').replace(/”/g, '”') .replace(/’/g, "'").replace(/—/g, '—')); if (!missing.length) { // Every quotation on the page, written out here whole so the claims gate can // see it pinned, each held against the one source it is attributed to, and // the list held against what the spec actually quotes so a new quotation // cannot arrive without joining it. const QUOTES = [ ['posix', 'The ERASE character shall delete the last character in the current line, if there is one.'], ['posix', 'The KILL character shall delete all data in the current line, if there is any.'], ['v7tty4', 'When an EOT is received, all the characters waiting to be read are immediately passed to the program, without waiting for a new-line, and the EOT is discarded.'], ['v7tty4', 'Thus if there are no characters waiting, which is to say the EOT occurred at the beginning of a line, zero characters will be passed back, and this is the standard end-of-file indication.'], ['posix', 'at most one line shall be returned'], ['posix', 'without losing information'], ['readline', 'If this character is read when there are no characters on the line, and point is at the beginning of the line, Readline interprets it as the end of input'], ['termios', 'not supported under Linux'], ['v7tty4', 'Currently this limit is 256 characters.'], ['v7tty4', 'When the input limit is reached all the saved characters are thrown away without notice.'], ]; const quotes = [...specText.matchAll(/“([^”]+)”/g)].map((m) => m[1]); check('every quotation on the page is in this list, and every one listed is quoted', quotes.length === QUOTES.length && QUOTES.every(([, q]) => quotes.includes(q)), `${quotes.length} on the page, ${QUOTES.length} listed`); for (const [k, q] of QUOTES) { check(`quoted as printed, from ${k}: "${q.slice(0, 56)}"`, has(k, q)); } check('V7 tty(4): the EOT sentence the third step quotes', has('v7tty4', 'When an EOT is received, all the characters waiting to be read are immediately passed to the program, without waiting for a new-line, and the EOT is discarded.')); check('V7 tty(4): the sentence the page is about', has('v7tty4', 'zero characters will be passed back, and this is the standard end-of-file indication.')); check('V7 tty(4): the input limit is 256 characters', has('v7tty4', 'Currently this limit is 256 characters.')); check('V7 tty.h: erase #, kill @, EOT 004, interrupt 0177 which is DEL', has('v7ttyh', "#define CERASE '#'") && has('v7ttyh', "#define CKILL '@'") && has('v7ttyh', '#define CEOT 004') && has('v7ttyh', '#define CINTR 0177 /* DEL */')); check('V7 tty.h: names the line discipline', has('v7ttyh', 'char t_line; /* line discipline */')); check('V7 tty.c: the end-of-file test is against the settable t_eofc', has('v7ttyc', 'if (c==tun.t_eofc) continue;')); check('V7 tty.c: and every erase, kill and EOF test is skipped after a backslash', has('v7ttyc', "if (bp[-1]!='\\\\') { if (c==tp->t_erase) {")); check('V7 tty.c: canon() is called when a program reads', has('v7ttyc', 'if (tp->t_canq.c_cc || canon(tp))')); check('V6 tty.c: the end-of-file test is against the constant CEOT', has('v6ttyc', 'if (c==CEOT)') && SRC.v6ttyc !== null && !SRC.v6ttyc.includes('t_eofc')); check('the manual is dated January, 1979, on the title page in the Bell Labs bundle', has('v7bun', 'January, 1979')); check('the Sixth Edition manual is dated May, 1975', has('v6intro', 'May, 1975')); // "Word for word": the two sentences, cut from each edition and compared. const cut = (t) => { const a = t.indexOf('When an EOT is received'); const b = t.indexOf('end-of-file indication.', a); return a >= 0 && b > a ? t.slice(a, b + 'end-of-file indication.'.length) : null; }; const v6 = SRC.v6tty4 && cut(SRC.v6tty4); const v7 = SRC.v7tty4 && cut(SRC.v7tty4); check('the two end-of-file sentences are word for word the same in 1975 and 1979', v6 !== null && v6 === v7 && v6.length > 300, `${v6 && v6.length} characters`); check('POSIX: a zero-byte read only when no bytes are waiting', has('posix', 'Thus, if there are no bytes waiting (that is, the EOF occurred at the beginning of a line), a byte count of zero shall be returned')); check('termios(3): VDISCARD is not supported under Linux', SRC.termios !== null && /VDISCARD \(not in POSIX; not supported under Linux/.test(SRC.termios)); check('termios(3): ERASE may be 0177 or 010', SRC.termios !== null && /VERASE \(0177, DEL, rubout, or 010, BS/.test(SRC.termios)); } /* ---- 7. what the page states, pinned --------------------------------------- */ { const page = join(ROOT, 'site/nibbles/line-discipline/index.html'); check('the page has been scaffolded', existsSync(page), 'scaffold the page from machines/line-discipline.json before this test can read it'); const html = existsSync(page) ? normal(readFileSync(page, 'utf8')) : ''; const flat = normal(html.replace(/<[^>]+>/g, ' ').replace(/“|”/g, '"') .replace(/’/g, "'")); // Every figure in the first ledger entry comes from the transcripts. // The longest n for which the group holds every one of the 9^n sequences. const groupsMax = (setting, size) => { const g = FIXTURE.groups.find((x) => x.setting === setting && x.read === size); if (!g) return -1; const alpha = new Set(FIXTURE.alphabet); const byLen = new Map(); for (const c of g.cases) { const k = fromHex(c.split(':')[0]); if (!k.every((b) => alpha.has(b))) continue; if (!byLen.has(k.length)) byLen.set(k.length, new Set()); byLen.get(k.length).add(c.split(':')[0]); } let n = 0; while (byLen.has(n + 1) && byLen.get(n + 1).size === FIXTURE.alphabet.length ** (n + 1)) n += 1; return n; }; check('the page says 14,425 cases, which is the committed count', flat.includes('There are 14,425 cases') && fixtureCount === 14425); check('a nine-byte alphabet', flat.includes('nine-byte alphabet') && FIXTURE.alphabet.length === 9); check('up to three keys under both settings and every read size, four at the default', flat.includes('every sequence of up to three keys') && flat.includes('every sequence of four') && groupsMax(0, 4096) === 4 && groupsMax(1, 4096) === 3 && groupsMax(0, 1) === 3, `${groupsMax(0, 4096)} / ${groupsMax(1, 4096)} / ${groupsMax(0, 1)}`); check('reads of 4096, 1, 2 and 3 bytes', flat.includes('reads of 4096, 1, 2 and 3 bytes') && same([...new Set(FIXTURE.groups.map((g) => g.read))].sort((a, b) => a - b), [1, 2, 3, 4096])); check('the kernel named is the kernel recorded', flat.includes(`Linux ${FIXTURE.kernel.release}`) && FIXTURE.kernel.release === '7.0.0-31-generic', FIXTURE.kernel.release); check('5,000 letters, 4,096 bytes, 4,095 letters, as measured', flat.includes('5,000 letters') && flat.includes('4,096 bytes') && flat.includes('4,095 letters') && FIXTURE.overlong_line.typed === 5000 && FIXTURE.overlong_line.first_read_bytes === 4096 && FIXTURE.overlong_line.letters_kept === 4095 && FIXTURE.overlong_line.kept_is_prefix === true && flat.includes('the first 4,095 letters')); check('the 1979 limit of 256 is the manual\'s', flat.includes('256 characters') && (SRC.v7tty4 === null || SRC.v7tty4.includes('256 characters'))); check('January 1979 and May 1975 are on the page', flat.includes('January 1979') && flat.includes('May 1975')); // The bytes the page names, each derived here rather than remembered. const ctrl = (ch) => ch.charCodeAt(0) & 0x1f; const BYTES = [['0x04', ctrl('D')], ['0x15', ctrl('U')], ['0x0d', 13], ['0x7f', 127], ['0x08', ctrl('H')]]; for (const [written, value] of BYTES) { check(`the page names ${written}, which is ${value}`, flat.includes(written) && Number(written) === value); } check('004 and 0177 are octal for 4 and 127', flat.includes('004') && flat.includes('0177') && parseInt('004', 8) === 4 && parseInt('0177', 8) === 127); // The sweep the checks table prints, counted here a second way. const keys = L.KEYS.length; const seqs = [1, 2, 3, 4, 5].reduce((t, n) => t + keys ** n, 0); const sweepRow = L.verify().find((r) => /sequences of up to five keys/.test(r.claim)); check('37,448 sequences of up to five keys is 8 + 64 + 512 + 4,096 + 32,768', seqs === 37448 && keys === 8 && sweepRow && sweepRow.claim.includes('37,448'), String(seqs)); let zero = 0; L.sweep(5, {}, (ids) => { // Counted from the key ids alone: a zero-byte read is a Ctrl-D that comes // first or straight after Enter, Ctrl-D, or a Ctrl-U or Backspace chain // that has emptied the line. Walked here with a depth counter. let depth = 0; for (const id of ids) { if (id === 'ctrl-d') { if (depth === 0) zero += 1; depth = 0; } else if (id === 'enter') depth = 0; else if (id === 'ctrl-u') depth = 0; else if (id === 'backspace') depth = Math.max(0, depth - 1); else depth += 1; } }); check('and 13,437 of their reads are zero bytes, counted from the keys alone', zero === 13437 && sweepRow && sweepRow.detail.startsWith('13,437'), String(zero)); // Assertion-shaped: the received sentence, which the page exists to refuse. const claimsEofKey = (t) => /Ctrl-D (sends|generates|types|is) (an |the )?(EOF|end[ -]of[ -]file)\b/i.test(t); check('the refusal check sees the received sentence when it is made', claimsEofKey('Press it and Ctrl-D sends an end of file to the program.')); check('and not the page\'s denial of it', !claimsEofKey('Ctrl-D sends no end of file.') && !claimsEofKey('Ctrl-D is not an end of file.')); check('the page never says Ctrl-D sends an end of file', html.length > 0 && !claimsEofKey(flat)); // Superlatives and origins: the page dates its manual, not the mechanism. const claimsOrigin = (t) => /\b(invented|introduced|first|originated)\b[^.]{0,60}\b(19\d\d|Seventh Edition|line discipline)\b/i.test(t); check('the origin check sees an origin claim when it is made', claimsOrigin('The end-of-file character was invented in 1979.')); check('and the page makes none', html.length > 0 && !claimsOrigin(flat), (flat.match(/\b(invented|introduced|first|originated)\b[^.]{0,60}/i) || [''])[0]); // Step 4 says the kernel kept every byte and every end of file at a smaller // read size. Checked here from the transcripts, case by case, against the // same keys read 4096 bytes at a time. { const big = new Map(CASES.filter((c) => c.size === 4096) .map((c) => [`${c.setting}/${Buffer.from(c.keys).toString('hex')}`, c])); let pairs = 0, lost = 0; for (const c of CASES.filter((x) => x.size !== 4096)) { const b = big.get(`${c.setting}/${Buffer.from(c.keys).toString('hex')}`); if (!b) continue; pairs += 1; const zeros = (rs) => rs.filter((x) => !x.length).length; if (!same(c.reads.flat(), b.reads.flat()) || zeros(c.reads) !== zeros(b.reads)) lost += 1; } check('kernel: a smaller read loses nothing, in every case held at both sizes', pairs > 4000 && lost === 0 && specText.includes('keeps that in every case its transcripts hold at a smaller size'), `${pairs} pairs, ${lost} differ`); } // Scope of what was checked against the kernel, and of the plain sentences // outside reviewers read as wider than they are (session-notes/verify-line-discipline.md). check('the kernel ledger entry does not say every result here was checked', !/Every result here was checked/.test(specText) && specText.includes('The checks table above runs the engine in this browser, not the kernel')); check('and step 4 does not claim the browser sweep for the kernel', !/every case in the sweep/.test(specText)); check('the release sentence is scoped to the default settings, since VEOL is a third', specText.includes('With the default settings, either of two bytes releases the line') && !/Two bytes release the line/.test(specText)); check('the opening scopes "never receives it", since Readline does receive it', /in its usual setting the program never receives it/.test(SPEC.lede) && !/0x04, and the program never receives it/.test(SPEC.lede)); check('a key may send more than one byte, as a cursor key does', specText.includes('It sends a byte, or for some keys a few')); check('the 1979 heading says DEL was the interrupt, not that it stopped the program', SPEC.ledger.some((l) => l.summary === 'In 1979 the erase key was #, and DEL was the interrupt key') && !/DEL stopped/.test(specText)); // dash, /bin/sh on the host the transcripts came from, keeps ICANON on at its // prompt, so Ctrl-D there does test the kernel. Only the bash prompt does not // (session-notes/verify2-line-discipline.md). check('the shell-prompt heading names bash, since a dash prompt is canonical mode', SPEC.ledger.some((l) => l.summary === 'Canonical mode only, and a bash prompt is not a test of it') && !/a shell prompt is not a test/.test(specText)); // The read sizes the page offers are the ones the engine's sweep checks. const offered = [...html.matchAll(/