#!/usr/bin/env python3 import contextlib import fcntl import json import os from pathlib import Path import subprocess import struct import sys import tempfile import termios import threading import time from ninep import Client @contextlib.contextmanager def session(binary, root, name, *options, socket_name=None, file=None, tty=False, network=None, launch=None, inherited=None, terminal=None): env = {key: value for key, value in os.environ.items() if not key.startswith('PARDES_')} env.update(HOME=str(root), XDG_RUNTIME_DIR=str(root), XDG_CONFIG_HOME=str(root / 'config'), PARDES_NOTIME='1', PARDES_DUMP=str(root / (name + '.dump.zon'))) env.update(inherited or {}) for language in ['RS', 'C', 'GO', 'TS', 'PY']: env['PARDES_LSP_' + language] = '' fixture = root / (name + '.txt') fixture.write_bytes(b'initial\n') address = root / ('pardes-9p-' + (socket_name or name) + '.sock') child = None master = slave = None reader = None try: args = [binary, '--detach=' + name, *options, str(file or fixture)] preexec = None if tty: master, slave = os.openpty() fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) env['TERM'] = 'xterm-256color' args = [binary, '--tty', '--9p=' + (socket_name or name), *options, str(file or fixture)] preexec = lambda: fcntl.ioctl(0, termios.TIOCSCTTY, 0) if launch is not None: args = [binary, *launch] child = subprocess.Popen(args, cwd=root, env=env, stdin=slave, stdout=slave if tty else subprocess.DEVNULL, stderr=subprocess.PIPE, start_new_session=True, preexec_fn=preexec) if terminal is not None: # Borrowed handles for input-driving probes, valid inside this context. terminal.update(pid=child.pid, input_fd=master) if launch is not None and socket_name is None: address = root / f'pardes-9p-{child.pid}.sock' if slave is not None: os.close(slave) slave = None def drain(): try: while os.read(master, 65536): pass except OSError: pass reader = threading.Thread(target=drain, daemon=True) reader.start() deadline = time.monotonic() + 10 while not address.exists(): if child.poll() is not None: raise AssertionError((args, child.returncode, child.stderr.read().decode())) if time.monotonic() > deadline: raise TimeoutError('default 9P socket did not appear') time.sleep(.005) with Client(address) as client: if network is not None: for dial in client.read('/listeners').decode().splitlines(): parts = dial.split('!') if parts[0] in ['tcp', 'quic']: assert len(parts) == 3 and 0 < int(parts[2]) < 65536, dial network[parts[0].upper()] = int(parts[2]) assert set(network) == {'TCP', 'QUIC'}, network yield client, address finally: if terminal is not None: terminal.clear() if child is not None: if child.poll() is None: child.terminate() try: child.wait(timeout=5) except subprocess.TimeoutExpired: child.kill() child.wait() child.stderr.close() if slave is not None: os.close(slave) if master is not None: os.close(master) if reader is not None: reader.join(timeout=1) assert not reader.is_alive(), 'terminal reader did not stop' def newest(client): """The newest pane's serial: /index is ordered by serial.""" return int(client.read('/index').splitlines()[-1].split()[0]) def new_pane(client, contents): """An open of /pane/new makes a pane; the read names it.""" serial = int(client.read('/pane/new')) if contents: client.write(f'/pane/{serial}/body', contents) return serial def look(client, path, source=None): """A right click on `path` from a pane (a fresh scratch by default).""" serial = source if source is not None else new_pane(client, b'') client.write(f'/pane/{serial}/look', f'{path}\n'.encode()) results = [int(row) for row in client.read('/look').split()] assert len(results) == 1 and results[0] != serial, (path, results) return results[0] def execute(client, serial, command): """A middle click on `command` from a pane.""" client.write(f'/pane/{serial}/exec', f'{command}\n'.encode()) def walk_tree(client, path='/', skip=('/log', '/os', '/screen')): """Stat every entry below `path`, as `find` does, without opening anything.""" seen = [] for name in client.list(path): child = (path.rstrip('/') + '/' + name) info = client.stat(child) seen.append((child, info)) if info['dir'] and child not in skip: seen.extend(walk_tree(client, child, skip)) return seen def discovery(binary, embedded=False): with tempfile.TemporaryDirectory(prefix='pardes-discovery-') as directory: root = Path(directory) with session(binary, root, 'discovery') as (client, _): top = client.list('/') assert top[:10] == ['README', 'index', 'status', 'look', 'exec', 'log', 'screen', 'listeners', 'pane', 'os'], top assert 'self' not in top and 'cons' not in top and 'new' not in top, top assert ('src' in top) == embedded, (top, embedded) before = client.read('/index') guide = client.read('/README') assert guide.count(b'\n') <= 45 and b'Msg hello' in guide and b'pane/new' in guide, guide assert client.stat('/README')['length'] == len(guide) # ls/stat/find over the whole tree, without opening, creates nothing. seen = walk_tree(client) names = {path for path, _ in seen} assert {'/index', '/status', '/look', '/exec', '/log', '/pane/1', '/pane/1/body', '/pane/1/name', '/pane/1/sel', '/pane/1/ctl', '/pane/1/tag', '/pane/1/event', '/pane/1/dot', '/pane/1/limit', '/pane/1/dirty', '/pane/1/mark', '/pane/1/scroll', '/pane/1/look', '/pane/1/exec'} <= names, sorted(names) assert '/pane/1/pty' not in names, 'the fixture file pane grew a pty/' assert not any(name.startswith('/self') or name.endswith('/rdsel') or name.endswith('/wrsel') for name in names), sorted(names) for path, info in seen: assert info['mode'] in (0o755, 0o644, 0o666, 0o444, 0o222, 0o555), (path, info) assert info['mtime'] > 0, (path, info) assert client.read('/index') == before, 'browsing created a pane' assert client.stat('/index')['length'] == len(before) > 0 index = client.stat('/index') assert index['mode'] == 0o444 and client.stat('/status')['mode'] == 0o444 assert client.stat('/exec')['mode'] == 0o666 assert client.stat('/pane/1/body')['mode'] == 0o666 and client.stat('/pane')['mode'] == 0o755 # A range file reads the pair it takes; a flag file reads 0 or 1. assert client.stat('/pane/1/addr')['length'] == 24 assert client.stat('/pane/1/limit')['length'] == 0 assert client.read('/pane/1/dirty') == b'0\n' assert client.read('/pane/1/scroll') == b'1\n' fixture = int(before.split()[0]) assert before.split()[1:3] == [b'text', b'0'] and before.endswith(b'/discovery.txt\n'), before # /status reports the editor; /exec has nothing to report yet. status = dict(line.split(maxsplit=1) for line in client.read('/status').decode().splitlines()) assert int(status['pid']) > 0 and status['version'] and int(status['panes']) >= 1, status assert client.read('/exec') == b'' # An open of /pane/new makes a pane and the read names it; each # open makes another. A stat makes none, which is why new can be # listed at all: ls -l stats every name a listing gave it. log = client.open('/log') first = int(client.read('/pane/new')) assert first == newest(client) second = int(client.read('/pane/new')) assert first != second and first != fixture, (first, second) assert 'new' in client.list('/pane'), 'new should be visible to ls' client.stat('/pane/new') assert newest(client) == second, 'a stat of new made a pane' assert client.read_fid(log) == f'new {first} {root}/+New\n'.encode() assert client.read_fid(log) == f'new {second} {root}/+New\n'.encode() assert set(client.list('/pane')) == {'new', str(fixture), str(first), str(second)} client.write(f'/pane/{first}/body', b'first pane', truncate=True) assert client.read(f'/pane/{first}/body') == b'first pane' assert client.read(f'/pane/{second}/body') == b'' assert client.stat(f'/pane/{first}/body')['length'] == len(b'first pane') rows = {int(row.split()[0]): row.split() for row in client.read('/index').splitlines()} assert rows[first][1:3] == [b'text', b'1'] and rows[second][1:3] == [b'text', b'0'], rows # name: read the file name, write to rename; sel: the selection. assert client.read(f'/pane/{first}/name') == f'{root}/+New\n'.encode() client.write(f'/pane/{first}/name', b'notes.txt\n') assert client.read(f'/pane/{first}/name') == f'{root}/notes.txt\n'.encode() assert client.read_fid(log) == f'rename {first} {root}/notes.txt\n'.encode() assert client.stat(f'/pane/{first}/name')['length'] == len(f'{root}/notes.txt\n') # Copying addr onto dot selects that range; sel reads and replaces it. client.write(f'/pane/{first}/addr', b'#0,#5') client.write(f'/pane/{first}/dot', client.read(f'/pane/{first}/addr')) assert client.read(f'/pane/{first}/dot') == client.read(f'/pane/{first}/addr') assert client.read(f'/pane/{first}/sel') == b'first' assert client.stat(f'/pane/{first}/sel')['length'] == 5 client.write(f'/pane/{first}/sel', b'FIRST') assert client.read(f'/pane/{first}/body') == b'FIRST pane' assert client.read(f'/pane/{first}/sel') == b'' # exec Msg shows text; exec Save writes the named file; rmdir closes. client.write(f'/pane/{first}/exec', b'Msg hello from 9P\n') assert 'hello from 9P' in ''.join(cell[0] for cell in client.screen()['cells']) assert client.read(f'/pane/{first}/dirty') == b'1\n' client.write(f'/pane/{first}/exec', b'Save\n') assert (root / 'notes.txt').read_bytes() == b'FIRST pane' assert client.read_fid(log) == f'save {first} {root}/notes.txt\n'.encode() assert client.read('/exec') == f'{first}\n'.encode() assert client.read(f'/pane/{first}/dirty') == b'0\n' client.remove(f'/pane/{second}') assert client.read_fid(log) == f'del {second} {root}/+New\n'.encode() assert str(second) not in client.list('/pane') # look opens a file; the serial reads back. (root / 'opened.txt').write_bytes(b'one\ntwo\nthree\n') client.write('/look', f'{root}/opened.txt:2\n'.encode()) opened = int(client.read('/look')) assert opened not in (fixture, first, second), opened assert client.read(f'/pane/{opened}/body') == b'one\ntwo\nthree\n' assert client.read(f'/pane/{opened}/name') == f'{root}/opened.txt\n'.encode() assert client.read_fid(log) == f'new {opened} {root}/opened.txt\n'.encode() client.write(f'/pane/{first}/look', f'{root}/opened.txt:3\n'.encode()) assert client.read('/look') == f'{opened}\n'.encode(), 'a repeated look targets the open pane' assert {str(fixture), str(first), str(opened)} <= set(client.list('/pane')) # Writing an event record back still performs the action it names. client.write(f'/pane/{first}/body', b'Msg via event', truncate=True) client.write(f'/pane/{first}/event', b'FX0 13\n') assert 'via event' in ''.join(cell[0] for cell in client.screen()['cells']) # A command line may not carry control characters, and nothing else # in the tree may be created or removed. refused = [lambda: client.write('/exec', b'Msg a\x00b'), lambda: client.write('/status', b'anything\n'), lambda: client.mkdir('/', 'x'), lambda: client.mkdir('/pane', 'one'), lambda: client.create('/pane', 'plain-file'), lambda: client.remove('/pane/1/body'), lambda: client.remove('/index')] for index, attempt in enumerate(refused): try: attempt() except OSError: pass else: raise AssertionError(f'refusal {index} was accepted') client.close(log) if embedded: assert client.read('/src/pardes.zig').startswith(b'const std') assert client.stat('/src/pardes.zig')['mode'] == 0o444 assert b'pub const Pardes' in client.read(f'/pane/{look(client, "/virtual/src/pardes.zig")}/body') print('9P discovery: listing/stat/find are inert; new, remove, look, exec, name, sel and log behave') def test(binary, quic=False): started = time.monotonic() for options, message in [ (['--fs'], b'no such option'), (['--fs9'], b'no such option'), (['--9p'], b'--9p needs a socket name'), (['--9p='], b'invalid 9P socket name'), (['--9p=a/b'], b'invalid 9P socket name'), (['--attach=work', '--9p=work'], b'--attach has none'), (['--9p-tcp'], b'--9p-tcp needs'), (['--9p-tcp='], b'--9p-tcp needs'), (['--9p-tcp=tcp!localhost!5640'], b'--9p-tcp needs'), (['--9p-tcp=quic!127.0.0.1!5640'], b'--9p-tcp needs'), (['--9p-tcp=tcp!127.0.0.1!5640', '--9p-tcp=tcp!127.0.0.1!5641'], b'specified twice'), (['--attach=work', '--9p-tcp=tcp!127.0.0.1!5640'], b'--attach has none'), (['--9p-quic'], b'--9p-quic needs'), (['--9p-quic='], b'--9p-quic needs'), (['--9p-quic=tcp!127.0.0.1!5640'], b'--9p-quic needs'), (['--mount=os=work'], b'invalid mount name'), (['--mount=peer='], b'invalid mount name or dial'), (['--mount=peer=one', '--mount=peer=two'], b'duplicate mount'), (['--mount=peer=tcp!127.0.0.1!0'], b'invalid mount dial'), (['--attach=work', '--mount=peer=one'], b'--attach has none'), ]: result = subprocess.run([binary, *options], capture_output=True, timeout=5) assert result.returncode == 1 and message in result.stderr, (options, result) with tempfile.TemporaryDirectory(prefix='pardes-fs-') as directory: root = Path(directory) with session(binary, root, 'renamed', '--9p=explicit', socket_name='explicit') as (named, named_address): assert named_address.name == 'pardes-9p-explicit.sock' assert not (root / 'pardes-9p-renamed.sock').exists() assert named.read('/pane/1/body') == b'initial\n' with session(binary, root, 'first') as (client, address): assert {'pane', 'index', 'status', 'look', 'exec', 'log', 'os'} <= set(client.list('/')) assert 'self' not in client.list('/') assert client.read('/pane/1/body') == b'initial\n' assert client.read('/os' + str(root / 'first.txt')) == b'initial\n' assert 'first.txt' in client.list('/os' + str(root)) assert client.read('/pane/1/../1/body') == b'initial\n' assert client.read('/os' + str(root) + '/../' + root.name + '/first.txt') == b'initial\n' listing = root / 'listing' listing.mkdir() (listing / 'broken').symlink_to(listing / 'missing') for number in range(24): (listing / f'entry-{number:02}').touch() with Client(address, msize=256) as small: assert set(small.list('/os' + str(listing))) == { f'entry-{number:02}' for number in range(24)} frozen = client.open('/screen') before = bytearray(client.read_fid(frozen, count=31)) client.write('/pane/1/body', b'screen changed\n') while chunk := client.read_fid(frozen, len(before)): before.extend(chunk) client.close(frozen) old_screen = json.loads(before) old_text = ''.join(cell[0] for cell in old_screen['cells']) assert 'initial' in old_text and 'screen changed' not in old_text assert 'screen changed' in ''.join(cell[0] for cell in client.screen()['cells']) payload = ('const value = "λ";\n' * 3000).encode() serial = new_pane(client, payload) body = f'/pane/{serial}/body' assert client.read(body) == payload client.write(body, b'replaced\n', truncate=True) assert client.read(body) == b'replaced\n' event = client.open(f'/pane/{serial}/event') client.write(body, b'event\n') record = client.read_fid(event) assert record.startswith(b'EI') and b'event\n' in record, record client.close(event) os_path = '/os' + str(root / 'first.txt') client.write(os_path, b'written through 9P\n', truncate=True) assert (root / 'first.txt').read_bytes() == b'written through 9P\n' assert client.read(os_path) == b'written through 9P\n' for index in range(12): with Client(address) as other: assert other.read(body) == b'replaced\nevent\n', index missing = False try: client.read('/pane/4294967295/body') except OSError: missing = True assert missing assert client.read(body) == b'replaced\nevent\n' if 'src' in client.list('/'): source = look(client, '/virtual/src/pardes.zig') assert b'pub const Pardes' in client.read(f'/pane/{source}/body') (root / 'index').write_bytes(b'OS takes precedence\n') opened = look(client, 'index') assert client.read(f'/pane/{opened}/body') == b'OS takes precedence\n' opened = look(client, '/n/self/index') index = client.read(f'/pane/{opened}/body') assert index.split()[0] == b'1' and b'first.txt' in index, index (root / 'explicit.txt').write_bytes(b'explicit OS mount\n') opened = look(client, '/n/os' + str(root / 'explicit.txt')) assert client.read(f'/pane/{opened}/body') == b'explicit OS mount\n' own = root / 'pardes-9p-second.sock' with session(binary, root, 'second', '--mount=peer=' + str(address), '--mount=own=' + str(own)) as (remote, _): opened = look(remote, '/n/own/pane/1/body') assert remote.read(f'/pane/{opened}/body') == b'initial\n' opened = look(remote, '/n/peer/pane/' + str(serial) + '/body') assert remote.read(f'/pane/{opened}/body') == b'replaced\nevent\n' remote.write(f'/pane/{opened}/body', b'saved to peer\n', truncate=True) remote.write(f'/pane/{opened}/exec', b'Save\n') assert client.read(body) == b'saved to peer\n' large = b'mounted file contents\n' * 60000 large_path = root / 'large.txt' large_path.write_bytes(large) opened = look(remote, '/n/peer/os' + str(large_path)) assert remote.read(f'/pane/{opened}/body') == large edited = large[:-3] + b'edited\n' remote.write(f'/pane/{opened}/body', edited, truncate=True) remote.write(f'/pane/{opened}/exec', b'Save\n') assert large_path.read_bytes() == edited assert client.read('/os' + str(large_path)) == edited with session(binary, root, 'runtime') as (remote, own_address): control = new_pane(remote, b'') execute(remote, control, 'Mount peer ' + str(address)) opened = look(remote, '/n/peer/pane/1/body', source=control) assert remote.read(f'/pane/{opened}/body') == client.read('/pane/1/body') execute(remote, control, 'Mount peer ' + str(own_address)) execute(remote, control, 'Unmount peer') remote.write(f'/pane/{opened}/body', b'runtime mounted Save\n', truncate=True) remote.write(f'/pane/{opened}/exec', b'Save\n') assert client.read('/pane/1/body') == b'runtime mounted Save\n' assert remote.read('/pane/1/body') == b'initial\n' roots = look(remote, '/n', source=control) assert b'/n/peer/\n' in remote.read(f'/pane/{roots}/body') remote.write(f'/pane/{opened}/exec', b'Del\n') execute(remote, control, 'Unmount peer') remote.write(f'/pane/{roots}/ctl', b'get\n') assert remote.read(f'/pane/{roots}/body') == b'/n/os/\n/n/self/\n' execute(remote, control, 'Mount peer ' + str(own_address)) opened = look(remote, '/n/peer/pane/1/body', source=control) assert remote.read(f'/pane/{opened}/body') == b'initial\n' with session(binary, root, 'mounted-startup', '--mount=peer=' + str(address), file='/n/peer/pane/1/body:1') as (remote, _): assert remote.read('/pane/1/body') == b'runtime mounted Save\n' client.write(f'/pane/{serial}/exec', b'Del\n') assert str(serial) not in client.list('/pane') for tty in [False, True]: name = 'restore tty space' if tty else 'restore detached space' with session(binary, root, name, tty=tty) as (old, address): old.write('/pane/1/body', b'dumped state\n', truncate=True) control = new_pane(old, b'') execute(old, control, 'Restore ' + str(root / 'missing dump.zon')) assert old.read('/pane/1/body') == b'dumped state\n' execute(old, control, 'Mount own ' + str(address)) execute(old, control, 'Dump') saved = root / (name + '.dump.zon') deadline = time.monotonic() + 5 while not saved.exists(): assert time.monotonic() < deadline, 'Dump did not write its state' time.sleep(.005) held_body = old.open('/pane/1/body') held_screen = old.open('/screen') old_ids = {int(row.split()[0]) for row in old.read('/index').splitlines()} assert old.read_fid(held_body) == b'dumped state\n' assert old.read_fid(held_screen, count=1) == b'{' old.write('/pane/1/body', b'changed after dump\n', truncate=True) try: execute(old, control, 'Restore ' + str(saved)) except (EOFError, ConnectionResetError, BrokenPipeError): pass try: old.read_fid(held_body) except (EOFError, ConnectionResetError, BrokenPipeError): pass else: raise AssertionError('Restore left an old body fid usable') with Client(address) as restored: restored_ids = [int(row.split()[0]) for row in restored.read('/index').splitlines()] assert len(restored_ids) == 2 and old_ids.isdisjoint(restored_ids), restored_ids file_id, control_id = restored_ids contents = restored.read(f'/pane/{file_id}/body') assert contents == b'dumped state\n', (contents, saved.read_text(), restored.read('/index')) assert restored.read(f'/pane/{control_id}/body') == b'', 'commands ran through exec, not the body' assert restored.screen()['cols'] == 80 opened = look(restored, '/n/own/os' + str(root / (name + '.txt')), source=control_id) assert restored.read(f'/pane/{opened}/body') == b'initial\n' with session(binary, root, 'forwarding') as (client, address): child_dir = root / 'child-working-directory' child_dir.mkdir() spaced = child_dir / 'space name.txt' spaced.write_bytes(b'first line\nsecond line\n') env = os.environ.copy() env.update(PARDES_9P=str(address), PARDES_PANE='1', PARDES_FORWARD_LOOK='1') for word, expected, selected, reuse in [('space name.txt:2:4', spaced.read_bytes(), [14, 14], False), ('/n/self/pane/1/body', b'initial\n', None, False), ('/virtual/pane/1/body:1:2-4', b'initial\n', [1, 4], True)]: before = {int(row.split()[0]) for row in client.read('/index').splitlines()} result = subprocess.run([binary, '--tty', word], cwd=child_dir, env=env, capture_output=True, timeout=3) assert result.returncode == 0, (word, result.stderr) after = {int(row.split()[0]) for row in client.read('/index').splitlines()} if reuse: assert after == before else: opened, = after - before assert client.read(f'/pane/{opened}/body') == expected assert client.read('/pane/1/body') == b'initial\n' if selected is not None: # The selection is dot; reading it takes no command at all. actual = list(map(int, client.read(f'/pane/{opened}/dot').split())) assert actual == selected, (word, actual, selected) addr = client.open('/pane/1/addr', 2) limit = client.open('/pane/1/limit', 2) for fid, text in [(addr, b'#0,#3'), (limit, b'#0,#3'), (addr, b'#0')]: client.rpc(118, struct.pack(' [--quic | --discovery]') test(str(Path(sys.argv[1]).resolve()), quic=len(sys.argv) == 3)