#!/usr/bin/env python3 import contextlib import fcntl import json import os import shutil import signal 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, address): top = client.list('/') # A set: which files the root holds, not the order a listing gives. assert set(top) - {'src', 'shaders'} == {'README', 'index', 'status', 'focus', 'ctl', 'commands', '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 answers the recent past and ends; `follow` written to the # same open makes its reads past that wait for new records. log = client.open('/log', 2) frozen = len(client.read_fid(log)) client.rpc(118, struct.pack(') deletes the addressed # range only, so the write after it replaces that range. scratch = new_pane(client, b'hello world\nsecond line\n') client.write(f'/pane/{scratch}/addr', b'#0,#5') client.write(f'/pane/{scratch}/data', b'HOWDY', truncate=True) assert client.read(f'/pane/{scratch}/body') == b'HOWDY world\nsecond line\n' # sam's regexps: ^ at any line; a miss says so and leaves no # address, so the data write after it refuses. client.write(f'/pane/{scratch}/addr', b'/^second/') assert client.read(f'/pane/{scratch}/addr') == b' 12 18 ' for path, data, why in ((f'/pane/{scratch}/addr', b'/nowhere/', 'no match for regexp'), (f'/pane/{scratch}/data', b'LOST', 'no address')): try: client.write(path, data) raise AssertionError(f'{path} took {data!r}') except OSError as refused: assert why in str(refused), refused assert client.read(f'/pane/{scratch}/body') == b'HOWDY world\nsecond line\n' # Through a mount both read as EINVAL; the log says which and why. assert f'err {scratch} addr: no match for regexp\n'.encode() in client.read('/log') client.remove(f'/pane/{scratch}') # A save the host cannot do fails the write that asked for it, # and the log's err record names the path and why. unsaved = new_pane(client, b'unsaved\n') try: client.write(f'/pane/{unsaved}/ctl', b'Save /nonexistent-pardes-dir/x.txt\n') raise AssertionError('a Save into a missing directory succeeded') except OSError as refused: assert 'Save /nonexistent-pardes-dir/x.txt: ' in str(refused), refused assert f'err {unsaved} ctl: Save /nonexistent-pardes-dir/x.txt: '.encode() in client.read('/log'), client.read('/log') client.remove(f'/pane/{unsaved}') # Sixteen connections fill the slots; the next is told why. extra = [Client(address) for _ in range(15)] try: try: Client(address).__exit__() raise AssertionError('a seventeenth connection was served') except OSError as refused: assert 'too many connections' in str(refused), refused finally: for other in extra: other.__exit__() deadline = time.monotonic() + 5 while b'err - 9p: too many connections' not in client.read('/log'): assert time.monotonic() < deadline, client.read('/log') time.sleep(.01) print('9P discovery: listing/stat/find are inert; new, remove, look, exec, name, sel, log, ctl lock, focus, the ctl split and commands behave') def run_file(binary): """pty/run: a line in, how it ended out, on the same open.""" def run(c, serial, line): fid = c.open(f'/pane/{serial}/pty/run', 2) try: c.rpc(118, struct.pack('= (3, 13))'], capture_output=True, text=True).stdout.strip() if version != 'True': return with tempfile.TemporaryDirectory(prefix='pardes-repl-') as directory: with session(binary, Path(directory), 'repl') as (client, _): def panes(client): return {int(row.split()[0]) for row in client.read('/index').splitlines()} before = panes(client) client.write('/pane/1/ctl', b'Tty\n') deadline = time.monotonic() + 10 while time.monotonic() < deadline and panes(client) == before: time.sleep(.05) tty = max(panes(client) - before) def body(): return client.read(f'/pane/{tty}/body').decode('utf-8', 'replace') def wait_for(text, seconds=15): deadline = time.monotonic() + seconds while time.monotonic() < deadline: if text in body(): return True time.sleep(.1) return False assert wait_for('$') or wait_for('>'), body() client.write(f'/pane/{tty}/pty/data', python.encode() + b' -q\r') assert wait_for('>>>'), body() client.write(f'/pane/{tty}/ctl', b'Repl python\n') # An exec on the REPL's own body goes to the REPL, as its event # record written back does: an empty range acts on its text. fid = client.open(f'/pane/{tty}/event', 2) try: record = b'MX0 0 0 10 print(6*7)\n' client.rpc(118, struct.pack(' assert (b'\n'.join(b'restored %d %d' % (old, new) for old, new in zip(sorted(old_ids), sorted(int(r.split()[0]) for r in restored.read('/index').splitlines()))) + b'\nrestoredcol ') in log, log 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() # PARDES_PID is what says "you are inside a pardes"; the socket # and the pane only say how to reach it. Without the pid the child # starts its own session, so this test used to pass only when the # runner itself happened to be running inside one. status = dict(line.split(maxsplit=1) for line in client.read('/status').decode().splitlines()) env.update(PARDES_9P=str(address), PARDES_PANE='1', PARDES_PID=status['pid']) env.pop('PARDES_FORWARD_LOOK', None) 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)