#!/usr/bin/env python3 """pardes 9P monkey: random 9P operations against a throwaway detached session. zig build monkey-9p -Dplatform=tty --prefix -- [--seed N] [--steps M] zig build monkey-9p -Dplatform=tty --prefix -- --replay FILE zig build monkey-9p -Dplatform=tty --prefix -- --shrink FILE python3 test/monkey9p.py [same flags] A run starts `pardes --detach` with its own HOME, XDG dirs and a short runtime dir in /tmp, speaks raw 9P to it (test/ninep.py), and for each step asks a GENERATOR for one concrete operation, runs it, then runs every INVARIANT. Everything random comes from --seed; every operation it ran is written to /runs/seed-N/ops.jsonl, concrete but for pane and column references ({p3}: the 4th serial of /index, mod its length, when the op runs, so a shrunk sequence still names live panes), and `--replay` runs the same requests again without the generator. A failure writes a bug record (seed, step, the replayable operations since that session began) to /bugs/, shrinks it by delta debugging over replays unless --no-shrink, and the run carries on in a fresh session. Plug-in points: a generator is a function `(ctx, rng) -> op dict` under @generator(weight); an op kind is a function `(sess, op) -> Result` under @op_kind(name); an invariant is a function `(ctx, res) -> str | None` under @invariant, returning why it failed. Safety: the session never sees PARDES_*, NINE_MOUNT or NAMESPACE; every payload that could reach a shell is drawn from an alphabet with no path or shell metacharacters (shell_safe), Edit text from a grammar without sam's w/e/r/f/b/B/D/n/!//| commands, and nothing is ever written, created or removed under /os or /src (the host filesystem). This process makes itself a child subreaper, so every shell the session starts is its descendant even after pardes dies, and teardown kills them all. """ import argparse import collections import ctypes import hashlib import json import os import random import re import select import shutil import signal import socket import struct import subprocess import sys import tempfile import threading import time sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from ninep import Client, string # noqa: E402 REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DEFAULT_OUT = os.path.join(os.path.dirname(REPO), '.scratch', 'monkey9p') # 9P message types. TWALK, TOPEN, TCREATE, TREAD, TWRITE, TCLUNK, TREMOVE, TSTAT, TFLUSH = 110, 112, 114, 116, 118, 120, 122, 124, 108 RERROR = 107 OREAD, OWRITE, ORDWR, OTRUNC = 0, 1, 2, 16 DMDIR = 0x80000000 TIMEOUT = 5.0 # every reply, unless the op is a documented slow one SLOW_TIMEOUT = 30.0 # Grep, Find, Dump, Restore, Tty, huge writes HELD_WAIT = 0.1 # how long a read of a held file is let wait before Tflush MSIZE = 65536 + 24 class Hang(Exception): """A 9P reply did not come within its timeout.""" class Dropped(Exception): """The server closed the connection (or reset it).""" class Ended(Exception): """pardes quit cleanly: exit 0, no crash file. Closing the session's last pane quits it (docs/typ/reference.typ: 'closing the session's last pane quits pardes'), and a terminal whose shell exits closes its pane, so this can follow any step; the run starts a fresh session and carries on.""" # -------------------------------------------------------------------------- # Wire: raw 9P with per-request timeouts and Tflush for held reads. class Wire(Client): def __init__(self, address, msize=MSIZE): super().__init__(address, timeout=TIMEOUT, msize=msize) self.next_fid = 100 def newfid(self): self.next_fid += 1 return self.next_fid def _send(self, kind, payload): self.tag = (self.tag + 1) % 65535 packet = struct.pack(' self.msize: raise ValueError(f'harness bug: a {len(packet)}-byte request exceeds msize {self.msize}') try: self.socket.sendall(packet) except socket.timeout: raise Hang('send did not drain within the timeout') except OSError as why: raise Dropped(f'send: {why}') return self.tag def _recv(self, timeout): self.socket.settimeout(timeout) try: size, kind, tag = struct.unpack(' fn(sess, op) -> Result INVARIANTS = [] # fn(ctx, res) -> str | None def generator(weight): def wrap(fn): GENERATORS.append((weight, fn)) return fn return wrap def op_kind(name): def wrap(fn): OPS[name] = fn return fn return wrap def invariant(fn): INVARIANTS.append(fn) return fn def host_path(path): """Guard: nothing mutating ever touches the served host tree.""" parts = split(path) return bool(parts) and parts[0] in (b'os', b'src') def is_held(path): return (path.rstrip('/') == '/log' or path.endswith('/event') or path.endswith('/pty/data') or path.endswith('/pty/run')) def slow(data): return len(data) > 65536 or any(w in data for w in (b'Grep', b'Find', b'Dump', b'Restore', b'Tty', b'Edit')) # -------------------------------------------------------------------------- # Op kinds: each takes a concrete op (what the ops log holds) and runs it. def dec(s): return s.encode('latin-1') def enc(b): return b.decode('latin-1') @op_kind('write') def op_write(sess, op): """open(path, mode); write data in the given chunk sizes; clunk.""" res = Result() path, data, mode = op['path'], dec(op['data']), op.get('mode', OWRITE) if host_path(path): res.note = 'refused by guard: host tree' return res w = sess.wire fid, err = w.walk_names(split(path)) if fid is None: res.add('walk', path, False, err) return res timeout = SLOW_TIMEOUT if slow(data) else TIMEOUT ok, body = w.call(TOPEN, struct.pack(' 1: n += 1 else: return if n: res.add('allow', path, True, n) def open_kind(path, mode): """An open the rule names: pane/new's (it makes a pane) and a truncation ('every write or truncation the tree refused'); others are 'open'.""" if path.rstrip('/') == '/pane/new' and not mode & 3: return 'open-new' if mode & OTRUNC and mode & 3: return 'trunc' return 'open' def check_served_lines(): assert served_lines(b'a\nb\n', [4], 8000) == [b'a', b'b'] assert served_lines(b'ab\n', [1, 1, 1], 8000) == [b'a', b'b', b''] assert served_lines(b'abcd', [2, 2], 2) == [b'abcd'] assert served_lines(b'ab\ncd', [3, 2], 8000) == [b'ab', b'cd'] def chunking(spec, n, cap): if n == 0: return [0] if spec == 'bytes': return [1] * n if isinstance(spec, list): out, total = [], 0 for s in spec: s = max(1, min(s, cap, n - total)) out.append(s) total += s if total >= n: return out while total < n: s = min(cap, n - total) out.append(s) total += s return out out, total = [], 0 while total < n: s = min(cap, n - total) out.append(s) total += s return out @op_kind('read') def op_read(sess, op): """open(path, OREAD); read from offset in chunks of count until EOF or cap.""" res = Result() path, count, offset = op['path'], op.get('count', 8192), op.get('offset', 0) w = sess.wire fid, err = w.walk_names(split(path)) if fid is None: res.add('walk', path, False, err) return res mode = op.get('mode', OREAD) ok, body = w.call(TOPEN, struct.pack('.\\*?![]{}\'"') def shell_safe(data): """No path or shell metacharacters, whatever else it holds.""" return bytes(b for b in data if b not in UNSAFE) RUNES = ['é', 'é', '日本', '\U0001f600', 'Δ', ' ', '​', '\u0080', '\u009f'] ODD = [b'', b'\n', b'\r\n', b'\0', b'\xff', b'\xfe\xff', b'\xe6\x97', b'\xc0\x80', b'\x1b[2J', b'\x7f', b'\t', b'\n\n\n', b' ', b' \n'] WORDS = [b'alpha', b'beta', b'foo', b'bar', b'x', b'hello', b'zz', b'0', b'1', b'-1', b'99999999999', b'on', b'off', b'follow', b'follow new', b'lock', b'unlock', b'clean', b'dirty', b'show', b'dot=addr', b'addr=dot', b'limit=addr', b'nomark', b'mark', b'get', b'put', b'del', b'delete', b'name', b'cleartag', b'answer', b'answer -', b'size 80 24', b'size 20 6', b'size 5000 5000', b'winsize 80 24', b'winsize 0 0', b'sig INT', b'sig TERM', b'sig HUP', b'sig KILL', b'sig BOGUS', b'exec', b'Placement acme', b'Placement pardes', b'Wrap on', b'Wrap off'] SHELL_LINES = [b'true', b'false', b'echo hi', b'echo m9p', b'sleep 1', b'ls', b'cat nonexist', b'printf abc', b'exit 3', b'seq 1 2000', b'yes | head -n 3000', b'date', b'pwd', b'env | wc'] DENY = {'Exit', 'Attach', 'Detach', 'Mount', 'Unmount', 'Tty9p', 'ClipYank', 'ClipYankMain', 'ClipPaste', 'ClipPasteBefore', 'ClipReplace', 'Shell', 'ThemeFile', 'EffectCode', 'Help', 'Tutor', 'Changelog', 'Rename', 'Hover', 'CodeAction', 'SelectRefs', 'Symbols', 'Diagnostics', 'WsDiagnostics', 'Callers', 'Callees', 'Supertypes', 'Subtypes', 'WsSymbols', 'Lspinfo', 'Lspwhy', 'Restore'} BUILTINS = ['Look', 'Exec', 'Kill', 'Dump', 'Msg', 'NextColor', 'Themes', 'DumpThemes', 'TreeContext', 'PdfFit', 'PdfTint', 'PdfSections', 'Petscii', 'Palette', 'Ascii', 'Save', 'New', 'Newcol', 'Del', 'Filter', 'Mode', 'Togglettymode', 'Edit', 'Undo', 'Redo', 'Collapse', 'Delcol', 'DelAbove', 'DelBelow', 'Tty', 'Repl', 'Joincol', 'Config', 'LocationsConfig', 'Messages', 'Mini', 'Find', 'Grep', 'Left', 'Down', 'Up', 'Right', 'Back', 'Forward', 'Last', 'Recent', 'Jumplist', 'Colors', 'Wrap', 'Tagbottom', 'Debug', 'FocusTint', 'Verbose', 'Motion', 'InactiveDim', 'DumpDir', 'Theme', 'Placement', 'BootShell', 'LookWord', 'Get', 'Put', 'Undo', 'Redo', 'Zerox', 'Snarf', 'Paste', 'Cut', 'Sort', 'Font', 'Local'] PANE_FILES = ['name', 'body', 'tag', 'ctl', 'addr', 'dot', 'limit', 'data', 'xdata', 'sel', 'dirty', 'mark', 'scroll', 'errors', 'event', 'look', 'exec', 'tagexec', 'pty', 'pty/run', 'pty/data', 'pty/ctl', 'pty/status'] ROOT_FILES = ['README', 'index', 'status', 'focus', 'ctl', 'commands', 'recent', 'look', 'exec', 'log', 'screen', 'listeners', 'layout', 'tag', 'tagexec', 'pane', 'pane/new', 'col', ''] COL_FILES = ['tag', 'ctl', 'exec'] WRITABLE = ['ctl', 'exec', 'look', 'tag', 'tagexec', 'focus', 'body', 'data', 'xdata', 'addr', 'dot', 'limit', 'name', 'sel', 'dirty', 'mark', 'scroll', 'errors', 'event', 'pty/run', 'pty/data', 'pty/ctl', 'log'] class Ctx: """What the generators and invariants see.""" def __init__(self, seed): self.seed = seed self.rng = random.Random(seed) self.sess = None self.step = 0 self.serials = [] self.cols = [] self.gone = [] # serials seen and since closed (stale) self.next_h = 0 self.last_index = b'' self.verbose = False def pick_serial(ctx, rng): """A live pane as a token, {pN}: the Nth serial of /index (mod its length) when the op runs, so a shrunk replay still names a pane that exists; or a stale or impossible serial, literally.""" r = rng.random() if ctx.serials and r < 0.85: return '{p%d}' % rng.randrange(64) if ctx.gone and r < 0.95: return rng.choice(ctx.gone) return rng.choice([0, 1, 999, 4294967295, 4294967296, -1]) def pick_col(ctx, rng): if ctx.cols and rng.random() < 0.85: return '{c%d}' % rng.randrange(16) return rng.choice([0, 99, 4294967295]) def odd_bytes(rng, n): out = bytearray() while len(out) < n: r = rng.random() if r < 0.5: out += rng.choice(WORDS) elif r < 0.7: out += rng.choice(RUNES).encode() elif r < 0.9: out += rng.choice(ODD) else: out.append(rng.randrange(256)) if rng.random() < 0.5: out += b' ' return bytes(out[:n]) def payload(rng, maxlen=64): """Plain or odd bytes, sometimes huge, with or without a newline.""" r = rng.random() if r < 0.35: data = rng.choice(WORDS) elif r < 0.55: data = odd_bytes(rng, rng.randrange(0, maxlen)) elif r < 0.62: data = rng.choice(ODD) elif r < 0.66: data = (rng.choice(WORDS) + b' ') * rng.randrange(100, 20000) # huge elif r < 0.7: data = b'x' * rng.choice([255, 256, 1023, 1024, 1025, 65535, 65536, 70000]) else: data = rng.choice(WORDS) + b' ' + odd_bytes(rng, rng.randrange(0, 12)) if rng.random() < 0.6 and not data.endswith(b'\n'): data += rng.choice([b'\n', b'\n', b'\r\n', b'\n\n']) return shell_safe(data) def weird_name(rng): r = rng.random() if r < 0.3: return 'x' * rng.choice([255, 256, 300, 1000]) if r < 0.5: return rng.choice(['..', '.', '', ' ', 'new', 'NEW', '01', '1 ', '\n', 'a\0b']) if r < 0.7: return enc(rng.choice(RUNES).encode()) return enc(bytes(rng.randrange(1, 256) for _ in range(rng.randrange(1, 20))).replace(b'/', b'_')) def random_path(ctx, rng, writable=False): r = rng.random() if r < 0.6: f = rng.choice(WRITABLE if writable else PANE_FILES) return f'/pane/{pick_serial(ctx, rng)}/{f}' if r < 0.75: f = rng.choice(['ctl', 'exec', 'look', 'tag', 'tagexec', 'focus', 'log'] if writable else ROOT_FILES) return '/' + f if r < 0.85: return f'/col/{pick_col(ctx, rng)}/{rng.choice(COL_FILES)}' if r < 0.93: return f'/pane/{pick_serial(ctx, rng)}/{weird_name(rng)}' return '/' + '/'.join(weird_name(rng) for _ in range(rng.randrange(1, 4))) def data_for(ctx, rng, path): """Payload a file is likely to take, often mutated.""" base = path.rsplit('/', 1)[-1] r = rng.random() if r < 0.25: return payload(rng) if base in ('addr', 'dot', 'limit', 'sel'): return address(rng) if base == 'ctl' and path.endswith('pty/ctl'): return rng.choice([b'sig INT\n', b'sig TERM\n', b'sig HUP\n', b'sig QUIT\n', b'sig KILL\n', b'winsize 80 24\n', b'winsize 1 1\n', b'winsize 99999 3\n', b'exec\n', b'sig\n']) if base in ('ctl', 'exec', 'tagexec'): return builtin_line(ctx, rng, path) if base == 'run': return shell_safe(rng.choice(SHELL_LINES)) + b'\n' if base == 'data' and '/pty/' in path: return shell_safe(rng.choice(SHELL_LINES)) + rng.choice([b'\r', b'\n', b'', b'\x03', b'\x04']) if base == 'event': return event_record(rng) if base == 'focus': return str(pick_serial(ctx, rng)).encode() + rng.choice([b'\n', b'']) if base == 'look': return shell_safe(rng.choice([b'alpha', b'alpha.txt', b'alpha.txt:2', b'utf8.txt:#3', b'nothere', b'bad.txt', b'crlf.txt:1', b'gamma', b'beta'])) + b'\n' if base in ('dirty', 'mark', 'scroll'): return rng.choice([b'0', b'1', b'0\n', b'1\n', b'2\n', b'', b'yes\n']) if base == 'log': return rng.choice([b'follow\n', b'follow new\n', b'follow', b'nope\n']) return payload(rng, 200) def mutate(rng, data, rounds=None): data = bytearray(data) for _ in range(rounds or rng.randrange(1, 4)): if not data: data += rng.choice(WORDS) continue i = rng.randrange(len(data)) r = rng.random() if r < 0.3: del data[i] elif r < 0.55: data[i:i] = shell_safe(rng.choice(ODD) or b'\0') elif r < 0.75: j = rng.randrange(i, len(data) + 1) data[i:i] = data[i:j] else: data[i:i] = rng.choice(RUNES).encode() return bytes(data) def builtin_line(ctx, rng, path): word = rng.choice(BUILTINS).encode() r = rng.random() if r < 0.4: line = word elif r < 0.7: line = word + b' ' + rng.choice(WORDS) elif r < 0.8: line = b'Edit ' + edit_command(rng) return line + b'\n' # Edit text keeps its / delimiters else: line = mutate(rng, word + b' ' + rng.choice(WORDS)) if rng.random() < 0.1: line = shell_safe(rng.choice(SHELL_LINES)) if rng.random() < 0.8: line += b'\n' return shell_safe(line) # sam's commands without the ones that reach files or shells. EDIT_TEXT = b'abcdghijklmopqstuvxyz0123456789 ACEGHIJKLMNOPQRSTUVWXYZ_-' def edit_text(rng): s = bytes(rng.choice(EDIT_TEXT) for _ in range(rng.randrange(0, 8))) if rng.random() < 0.3: s += rng.choice(RUNES).encode() return s def regex(rng): parts = [edit_text(rng) or b'a'] for _ in range(rng.randrange(0, 4)): parts.append(rng.choice([b'(a|b)', b'^', b'$', b'[a-z]', b'[^ ]', b'.*', b'\\n', '[é日]'.encode(), b'x+', b'(', b'[', b'\\', b'a**', b'(((a)))', b'[z-a]', (b'a|' * rng.randrange(1, 300)) + b'b'])) parts.append(edit_text(rng)) return b''.join(parts) def address(rng): """sam addresses: valid, near-valid and garbage.""" simple = [b'0', b'$', b'.', b',', b';', b'#0', b'#3', b'#99999', b'1', b'2', b'3:2', b'1:1', b'0:0', b'#1,#2', b'1,$', b'2,1', b'-', b'+', b'-1', b'+2', b'#-1', b'99999', b'1:99', b'?a?'] r = rng.random() if r < 0.4: a = rng.choice(simple) elif r < 0.7: a = b'/' + regex(rng) + b'/' elif r < 0.85: a = rng.choice(simple) + rng.choice([b',', b';', b'+', b'-']) + rng.choice(simple + [b'/a/']) else: a = mutate(rng, rng.choice(simple) + b'/' + regex(rng) + b'/') return a + rng.choice([b'', b'\n']) def edit_command(rng): t = lambda: edit_text(rng) # noqa: E731 cmds = [lambda: b',x/' + regex(rng) + b'/c/' + t() + b'/', lambda: b's/' + regex(rng) + b'/' + t() + b'/g', lambda: b'a/' + t() + b'/', lambda: b'i/' + t() + b'/', lambda: b'c/' + t() + b'/', lambda: b'd', lambda: b',d', lambda: b',y/' + regex(rng) + b'/d', lambda: b',x/' + regex(rng) + b'/g/' + regex(rng) + b'/d', lambda: b',x/' + regex(rng) + b'/v/' + regex(rng) + b'/c/' + t() + b'/', lambda: b'1,2m$', lambda: b'1t0', lambda: b'k', lambda: b'p', lambda: b'=', lambda: b'u', lambda: b'{\na/' + t() + b'/\ni/' + t() + b'/\n}', lambda: b',x/\\n/a/' + t() + b'/', lambda: b'0,$s/' + regex(rng) + b'/&&/g'] cmd = rng.choice(cmds)() if rng.random() < 0.4: cmd = address(rng).rstrip(b'\n') + cmd if rng.random() < 0.2: cmd = mutate(rng, cmd) # Mutation never introduces a banned command letter or shell char. cmd = bytes(b for b in cmd if b not in b'werfbBDn!<>|') return cmd def event_record(rng): origin = rng.choice(b'EFKMZ') action = rng.choice(b'xXlLiIdDZ') q0, q1 = rng.choice([0, 1, 5, 10]), rng.choice([0, 2, 7, 1 << 40, -1]) rec = bytes([origin, action]) + f'{q0} {q1} {rng.choice([0, 1, 2, 8])} '.encode() text = shell_safe(edit_text(rng)) rec += str(len(text)).encode() + b' ' + text + b'\n' if rng.random() < 0.3: rec = mutate(rng, rec) return shell_safe(rec) def new_handle(ctx): ctx.next_h += 1 return f'h{ctx.next_h}' @generator(30) def gen_write(ctx, rng): path = random_path(ctx, rng, writable=True) data = data_for(ctx, rng, path) mode = rng.choice([OWRITE, OWRITE, ORDWR, OWRITE | OTRUNC, ORDWR | OTRUNC, OREAD]) r = rng.random() chunks = 'bytes' if r < 0.08 and len(data) <= 64 else ( [rng.randrange(1, 16) for _ in range(4)] if r < 0.15 else 'whole') op = {'op': 'write', 'path': path, 'data': enc(data), 'mode': mode, 'chunks': chunks} if rng.random() < 0.05: op['offset'] = rng.choice([1, 100, 1 << 40]) return op @generator(14) def gen_read(ctx, rng): path = random_path(ctx, rng) return {'op': 'read', 'path': path, 'count': rng.choice([1, 2, 7, 64, 200, 8192, 1 << 20]), 'offset': rng.choice([0, 0, 0, 1, 3, 100, 1 << 33]), 'reads': rng.choice([1, 2, 8])} @generator(4) def gen_stat(ctx, rng): path = random_path(ctx, rng) if rng.random() < 0.2: path = '/os/' + rng.choice(['etc', 'etc/hosts', 'nonexistent', 'x' * 300]) return {'op': 'stat', 'path': path} @generator(5) def gen_newpane(ctx, rng): if rng.random() < 0.7: return {'op': 'read', 'path': '/pane/new', 'count': 64, 'reads': 1} return {'op': 'hold', 'h': new_handle(ctx), 'path': '/pane/new', 'mode': rng.choice([OREAD, ORDWR])} @generator(2) def gen_remove(ctx, rng): r = rng.random() if r < 0.6: path = f'/pane/{pick_serial(ctx, rng)}' elif r < 0.85: path = f'/col/{pick_col(ctx, rng)}' else: path = random_path(ctx, rng) return {'op': 'remove', 'path': path} @generator(2) def gen_create(ctx, rng): where = rng.choice(['/col', '/pane', '/', f'/pane/{pick_serial(ctx, rng)}']) return {'op': 'create', 'path': where, 'name': weird_name(rng) if rng.random() < 0.5 else 'new', 'perm': rng.choice([0o666, DMDIR | 0o777]), 'mode': rng.choice([OREAD, OWRITE])} @generator(2) def gen_clear_window(ctx, rng): """Down to the empty window (every pane closed), sometimes rebuilt.""" keep = rng.choice(ctx.serials) if ctx.serials and rng.random() < 0.8 else None ops = [{'op': 'remove', 'path': f'/pane/{s}'} for s in ctx.serials if s != keep] if rng.random() < 0.5: ops += [{'op': 'remove', 'path': f'/col/{c}'} for c in ctx.cols] if rng.random() < 0.6: ops.append({'op': 'read', 'path': '/pane/new', 'count': 64, 'reads': 1}) return {'op': 'seq', 'ops': ops} @generator(6) def gen_hold(ctx, rng): held = [h for h in (ctx.sess.handles if ctx.sess else {})] r = rng.random() if not held or r < 0.35: path = random_path(ctx, rng) if rng.random() < 0.3: path = rng.choice(['/log', f'/pane/{pick_serial(ctx, rng)}/event', '/screen', '/index', f'/pane/{pick_serial(ctx, rng)}/ctl']) return {'op': 'hold', 'h': new_handle(ctx), 'path': path, 'mode': rng.choice([OREAD, ORDWR, OWRITE])} h = rng.choice(held) path = ctx.sess.handles[h][1] if r < 0.6: return {'op': 'hwrite', 'h': h, 'data': enc(data_for(ctx, rng, path))} if r < 0.85: return {'op': 'hread', 'h': h, 'count': rng.choice([1, 16, 8192]), 'offset': rng.choice([None, None, 0, 5])} return {'op': 'clunk', 'h': h} @generator(6) def gen_shell(ctx, rng): s = pick_serial(ctx, rng) line = shell_safe(rng.choice(SHELL_LINES)) r = rng.random() if r < 0.25: return {'op': 'write', 'path': f'/pane/{s}/ctl', 'data': enc(b'Tty\n'), 'mode': OWRITE} if r < 0.45: return {'op': 'write', 'path': f'/pane/{s}/pty/run', 'data': enc(line + b'\n'), 'mode': ORDWR} if r < 0.6: return {'op': 'write', 'path': f'/pane/{s}/pty/data', 'data': enc(line + b'\r'), 'mode': OWRITE} if r < 0.75: return {'op': 'write', 'path': f'/pane/{s}/pty/ctl', 'data': enc(rng.choice([b'sig INT\n', b'sig TERM\n', b'sig KILL\n', b'sig HUP\n'])), 'mode': OWRITE} return {'op': 'write', 'path': rng.choice(['/exec', f'/pane/{s}/exec']), 'data': enc(line + b'\n'), 'mode': OWRITE} @generator(6) def gen_edit(ctx, rng): s = pick_serial(ctx, rng) path = rng.choice([f'/pane/{s}/ctl', f'/pane/{s}/exec', '/exec']) return {'op': 'write', 'path': path, 'data': enc(b'Edit ' + edit_command(rng) + b'\n'), 'mode': OWRITE} @generator(8) def gen_addr(ctx, rng): s = pick_serial(ctx, rng) ops = [{'op': 'write', 'path': f'/pane/{s}/addr', 'data': enc(address(rng)), 'mode': OWRITE}] r = rng.random() if r < 0.4: ops.append({'op': 'read', 'path': f'/pane/{s}/{rng.choice(["xdata", "data", "addr", "dot"])}', 'count': 8192, 'reads': 2}) elif r < 0.7: ops.append({'op': 'write', 'path': f'/pane/{s}/{rng.choice(["data", "xdata"])}', 'data': enc(payload(rng, 20)), 'mode': rng.choice([OWRITE, OWRITE | OTRUNC])}) return {'op': 'seq', 'ops': ops} @generator(3) def gen_tag(ctx, rng): path = rng.choice([f'/pane/{pick_serial(ctx, rng)}/tag', '/tag', f'/col/{pick_col(ctx, rng)}/tag']) return {'op': 'write', 'path': path, 'data': enc(payload(rng, 40)), 'mode': rng.choice([OWRITE, OWRITE | OTRUNC])} @generator(3) def gen_event(ctx, rng): s = pick_serial(ctx, rng) return {'op': 'hold', 'h': new_handle(ctx), 'path': f'/pane/{s}/event', 'mode': ORDWR} @generator(2) def gen_dump_restore(ctx, rng): r = rng.random() if r < 0.5: return {'op': 'write', 'path': '/ctl', 'data': enc(rng.choice([b'Dump\n', b'Dump', b'Dump m9pdump\n'])), 'mode': OWRITE} return {'op': 'restore', 'data': enc(rng.choice([b'Restore\n', b'Restore\n', b'Restore m9pdump\n', b'Restore nothere\n']))} @generator(3) def gen_host(ctx, rng): name = rng.choice(list(FIXTURES) + ['fresh.txt']) if rng.random() < 0.5: return {'op': 'host', 'action': 'rm', 'name': name} return {'op': 'host', 'action': 'write', 'name': name, 'data': enc(payload(rng, 100))} @generator(3) def gen_look_fixture(ctx, rng): name = rng.choice(list(FIXTURES) + ['fresh.txt']) suffix = rng.choice([b'', b':2', b':#4', b':1:3', b':/beta/']) return {'op': 'write', 'path': rng.choice(['/look', f'/pane/{pick_serial(ctx, rng)}/look']), 'data': enc(name.encode() + suffix + b'\n'), 'mode': OWRITE} # -------------------------------------------------------------------------- # Log window: the records one step caused, between two unique markers. MARK = re.compile(r'unknown control message "(m9p-mark-\d+)"') def write_marker(sess, step): """An unknown ctl word logs `err - ctl: unknown control message ""`.""" word = f'm9p-mark-{step}' ok, why = sess.wire.write_path('/ctl', word.encode() + b'\n') if ok: return None, 'marker write was taken' prev, sess.marker = sess.marker, word if sess.follower is not None: end = sess.follower.wait_for(f'"{word}"') if end is None: return None, 'marker record never reached the follower' lines = sess.follower.lines[:end] start = 0 if prev is not None: for i in range(len(lines) - 1, -1, -1): if f'"{prev}"' in lines[i]: start = i + 1 break else: return None, None window = lines[start:] del sess.follower.lines[:end] return window, None lines = sess.wire.read_path('/log').decode(errors='replace').splitlines() end = next((i for i in range(len(lines) - 1, -1, -1) if f'"{word}"' in lines[i]), None) if end is None: return None, 'marker record missing from /log' if prev is None: return None, None start = next((i for i in range(end - 1, -1, -1) if f'"{prev}"' in lines[i]), None) if start is None: return None, None # the ring moved past it return lines[start + 1:end], None COUNT = re.compile(r'^(.*) \(x(\d+)\)$') def occurrences(lines, kind): """Records of `kind` (err|msg), counting `(xN)` repeats (docs/typ/reference.typ: a record said again is counted; a follower sees each count as a line of its own).""" out, last_text, last_n = [], None, 0 for line in lines: if not line.startswith(kind + ' '): last_text = None continue m = COUNT.match(line) text, n = (m.group(1), int(m.group(2))) if m else (line, 1) if text == last_text: new = n - last_n else: new = n out.extend([text] * max(new, 0)) last_text, last_n = text, n return out # -------------------------------------------------------------------------- # Invariants: small, cheap, each with its doc citation. @invariant def alive(ctx, res): """Never crashes or panics (src/crash.zig: a panic leaves its crash file).""" if not ctx.sess.alive(): return f'pardes died (exit {ctx.sess.proc.returncode})' if ctx.sess.has_crash_file(): return 'crash file written: ' + ctx.sess.crash_text()[-2000:] return None @invariant def one_failure_rule(ctx, res): """docs/typ/reference.typ 'One rule for what fails': a write fails whenever what it asked for fails ... and logs its reason exactly once, as `err : `, with no `msg` for it. What is not a failure: a look that finds nothing answers nothing and logs one `err`, the write succeeding.""" if res.window is None or res.hung_up: return None writes = res.writes() # The rule is a write's: a refused open (pane/new's and an OTRUNC one # too), create or remove logs no err, nor does a write to pane/new # (`permission denied`: it is only read). failed = [w for w in writes if w[2] is False and w[0] in ('write', 'clunk-write') and w[1].rstrip('/') != '/pane/new'] errs = occurrences(res.window, 'err') msgs = occurrences(res.window, 'msg') # Allowed only the Twrites that succeeded (allowances): a failed one # logs its one err. allowed = sum(r[3] for r in res.requests if r[0] == 'allow') # docs/typ/reference.typ 'A write of command lines ... what is left when it closes runs at # the close ... and its failure is in the log alone'. closes = [r for r in res.requests if r[0] == 'close-runs'] lo, hi = len(failed), len(failed) + allowed + len(closes) if not lo <= len(errs) <= hi: return (f'{len(failed)} failed write(s) logged {len(errs)} err record(s) ' f'(allowed {lo}..{hi}); failed={[(w[0], w[1], w[3]) for w in failed]} window={res.window}') # A msg is a failed write's only when no Twrite of the op succeeded: a # line cut across Twrites runs a piece a Twrite, and a piece that ran # (a Save) says so while a later one is refused. succeeded = [w for w in writes if w[2] is True and w[0] == 'write'] # A file watch's notice (` deleted on disk ...`, `changed on # disk`, a stamped `reloaded ` or `reloaded theme`) is the host's # news, said whenever it arrives: no write's. msgs = [m for m in msgs if ' deleted on disk (' not in m and ' changed on disk (' not in m and ' reloaded ' not in m] if failed and msgs and not succeeded: return f'a failed write also logged msg: {msgs}; failed={[(w[1], w[3]) for w in failed]}' return None LINE_FILES = ('look', 'exec', 'tagexec', 'ctl') @invariant def close_runs_only_line_files(ctx, res): """docs/typ/reference.typ: 'A write of command lines -- to look, exec, tagexec, a ctl of the root, a pane or a column, or a column's exec -- runs each line once it is whole ... what is left when it closes runs at the close'. Another file that fails only at its close breaks 'a write fails whenever what it asked for fails' (or the list is short).""" if res.window is None: return None closes = [r[1] for r in res.requests if r[0] == 'close-runs'] failed_files = {w[1].rsplit('/', 1)[-1] for w in res.writes() if w[2] is False} for line in occurrences(res.window, 'err'): m = re.match(r'err \S+ ([^:]+):', line) if not m: continue name = m.group(1).rsplit('/', 1)[-1] if name in LINE_FILES or name in failed_files: continue for path in closes: if path.rsplit('/', 1)[-1] == name: return f'a write to {name} with no newline was answered, then failed at the close: {line}' return None @invariant def index_matches_pane_dirs(ctx, res): """docs/typ/reference.typ /index: one line per pane; /pane// each pane's directory.""" w = ctx.sess.wire first = w.read_path('/index') fid, err = w.walk_names([b'pane']) if fid is None: return f'walking /pane failed: {err}' ok, _ = w.call(TOPEN, struct.pack(' {"ok" if r[2] else ("held" if r[2] is None else "Rerror")}' f'{": " + str(r[3]) if r[3] else ""}') for line in window or []: print(f' log: {line}') for check in INVARIANTS: problem = check(ctx, res) if problem: raise Failure(check.__name__, problem, ctx.step) # The column list, for the generators. fid, err = sess.wire.walk_names([b'col']) if fid is not None: sess.wire.call(TOPEN, struct.pack(' deadline: return False budget[0] -= 1 nonlocal sig f = self.execute([{'op': 'fresh', 'follower': follower}] + candidate, run_dir) if f is None: return False if sig is None: if kind is not None and f.kind != kind: return False sig = f.signature() return True return f.signature() == sig if not fails(body): # One more try with the other log mode, then give up. follower = not follower if not fails(body): header['shrink'] = 'did not reproduce on replay' write_ops(path, header, ops) print(f' not reproduced on replay: {path}', flush=True) return small = ddmin(body, fails) # Then shrink each op's payload. small = shrink_payloads(small, fails) header['shrink'] = f'{len(body)} -> {len(small)} ops' out = path.replace('.jsonl', '.min.jsonl') write_ops(out, header, [{'op': 'fresh', 'follower': follower}] + small) print(f' shrunk {len(body)} -> {len(small)} ops: {out}', flush=True) def ddmin(items, fails): """Zeller's delta debugging: a 1-minimal failing subsequence.""" n = 2 # Trailing ops after the failure never matter; the failing op is last. while len(items) >= 2: size = max(1, len(items) // n) chunks = [items[i:i + size] for i in range(0, len(items), size)] reduced = False for i in range(len(chunks)): complement = [op for j, c in enumerate(chunks) if j != i for op in c] if complement and fails(complement): items, n, reduced = complement, max(n - 1, 2), True break if not reduced: if n >= len(items): break n = min(len(items), n * 2) return items def shrink_payloads(ops, fails): """Halve long data payloads while the failure stays.""" ops = [dict(op) for op in ops] for i, op in enumerate(ops): while 'data' in op and len(op['data']) > 8: half = dict(op, data=op['data'][:len(op['data']) // 2]) trial = ops[:i] + [half] + ops[i + 1:] if fails(trial): ops[i] = op = half else: break if op.get('chunks') not in (None, 'whole'): whole = dict(op, chunks='whole') if fails(ops[:i] + [whole] + ops[i + 1:]): ops[i] = whole return ops def write_ops(path, header, ops): with open(path, 'w') as f: f.write(json.dumps(header) + '\n') for op in ops: f.write(json.dumps(op) + '\n') def read_ops(path): with open(path) as f: lines = [json.loads(line) for line in f if line.strip()] return lines[0], lines[1:] def main(): ap = argparse.ArgumentParser(description=__doc__.split('\n\n')[0]) ap.add_argument('binary') ap.add_argument('--seed', type=int, action='append', help='seed (repeatable); default 1') ap.add_argument('--steps', type=int, default=1000) ap.add_argument('--replay', help='run an ops log (or bug record) exactly') ap.add_argument('--shrink', help='shrink a bug record by delta debugging over replays') ap.add_argument('--no-shrink', action='store_true', help='record failures without shrinking them') ap.add_argument('--shrink-budget', type=int, default=300, help='replays a shrink may spend') ap.add_argument('--shrink-seconds', type=float, default=600) ap.add_argument('--smoke', action='store_true', help='fixed seed, few steps, fail on any failure') ap.add_argument('--progress', type=int, default=500) ap.add_argument('--verbose', '-v', action='store_true', help='replay: print each request and log record') ap.add_argument('--out', default=DEFAULT_OUT) args = ap.parse_args() check_served_lines() args.binary = os.path.abspath(args.binary) for key in [k for k in os.environ if k.startswith('PARDES_') or k in ('NINE_MOUNT', 'NAMESPACE')]: del os.environ[key] become_subreaper() signal.signal(signal.SIGTERM, lambda *_: sys.exit(143)) runner = Runner(args) try: return run_main(runner, args) finally: sweep(f'{os.getpid()}-') for d in RUNTIME_DIRS: shutil.rmtree(d, ignore_errors=True) def run_main(runner, args): if args.replay: header, ops = read_ops(args.replay) run_dir = os.path.join(args.out, 'runs', f'replay-{os.getpid()}') os.makedirs(run_dir, exist_ok=True) f = runner.execute(ops, run_dir, verbose=args.verbose) print(f'replay {args.replay}: ' + (f'FAIL at op {f.step}: {f.kind}: {f.why}' if f else 'passed')) return 1 if f else 0 if args.shrink: runner.shrink(args.shrink) return 0 if args.smoke: args.no_shrink = True found = runner.fuzz(20260929, args.steps if args.steps != 1000 else 800) return 1 if found else 0 total = 0 for seed in args.seed or [1]: total += len(runner.fuzz(seed, args.steps)) return 1 if total else 0 if __name__ == '__main__': sys.exit(main())