#!/usr/bin/env python3 """A session that opens, reads and saves its own 9P tree through a mount. The editor runs under `9ns --mntgen`, so /mnt/9p/pardes/ inside its namespace is its own tree, and is driven over its socket from outside. This used to hang the editor outright: the realpath, the stat and the read went out through the mount and came back as 9P requests only the editor's loop could answer, while that loop was blocked making them. Now the connection's task answers them while the editor waits (pardes.turn). Needs 9ns (cloud9) on PATH or in ~/.local/bin, and a runtime directory; it skips, loudly, without them. """ import fcntl import os from pathlib import Path import pty import shutil import signal import struct import sys import termios import time sys.path.insert(0, str(Path(__file__).resolve().parent)) from ninep import Client def run(binary): ns = shutil.which('9ns') or str(Path.home() / '.local/bin/9ns') runtime = os.environ.get('XDG_RUNTIME_DIR') if not os.path.exists(ns) or not runtime: print('selfmount: skipped (needs 9ns and XDG_RUNTIME_DIR)') return 0 name = 'selfmount%d' % os.getpid() sock = f'{runtime}/pardes-9p-{name}.sock' fixture = Path(f'/tmp/{name}.txt') fixture.write_bytes(b'initial\n') env = {k: v for k, v in os.environ.items() if not k.startswith('PARDES_')} env.update(PARDES_NOTIME='1', TERM='xterm-256color', PARDES_DUMP=f'/tmp/{name}.dump.zon') for language in ['RS', 'C', 'GO', 'TS', 'PY']: env['PARDES_LSP_' + language] = '' pid, master = pty.fork() if pid == 0: os.execve(ns, ['9ns', '--mntgen', '--', binary, '--tty', '--9p=' + name, str(fixture)], env) fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', 30, 100, 0, 0)) os.set_blocking(master, False) tail = bytearray() def drain(): try: while True: chunk = os.read(master, 65536) if not chunk: return tail.extend(chunk[-2000:]) except OSError: pass failures = [] def check(condition, what): print(('ok ' if condition else 'FAIL ') + what) if not condition: failures.append(what) try: deadline = time.monotonic() + 15 while not os.path.exists(sock): drain() if time.monotonic() > deadline: raise TimeoutError('socket never appeared: ' + tail.decode(errors='replace')[-400:]) time.sleep(.02) time.sleep(.3) mount = f'/mnt/9p/pardes/{name}' with Client(sock) as client: index = client.read('/index').decode() rows = [row.rsplit(maxsplit=1)[0].split(maxsplit=3) for row in index.splitlines()] first = rows[0][0] # A Look at the tree's own index: realpath, stat and read all go # out through 9ns and back in, and the write returns when the # pane is open. started = time.monotonic() client.write('/look', (mount + '/index\n').encode()) took = time.monotonic() - started check(took < 5, f'look at the mounted index returned ({took:.2f}s)') rows = [row.rsplit(maxsplit=1)[0].split(maxsplit=3) for row in client.read('/index').decode().splitlines()] opened = [row for row in rows if len(row) == 4 and row[3] == mount + '/index'] check(len(opened) == 1, 'a pane opened on the mounted index') if opened: body = client.read(f'/pane/{opened[0][0]}/body').decode() check(body.startswith(index.splitlines()[0]), 'its body is the index as served') # A Look at pane 1's body through the mount, an edit of the copy, # and a Save: the save writes back through the mount into pane 1, # and the exec returns once it has. client.write('/look', f'{mount}/pane/{first}/body\n'.encode()) rows = [row.rsplit(maxsplit=1)[0].split(maxsplit=3) for row in client.read('/index').decode().splitlines()] mirror = [row for row in rows if len(row) == 4 and row[3] == f'{mount}/pane/{first}/body'] check(len(mirror) == 1, 'a pane opened on the mounted body') if mirror: pane = mirror[0][0] client.write(f'/pane/{pane}/addr', b'#0,#0') client.write(f'/pane/{pane}/data', b'HELLO ') client.write(f'/pane/{pane}/exec', b'Save\n') check(client.read(f'/pane/{first}/body') == b'HELLO initial\n', f'Save wrote back through the mount into pane {first}') text = ''.join(cell[0] for cell in client.screen()['cells']) check('saved ' + mount in text, 'the editor reported the save') # Through the kernel mount a write is cut at its msize: a # 1000-line burst and a 50 KB Edit block each still run whole, # line by line, and no piece of a line runs as a shell command. # sh: bash's and fish's prompt files are not reachable in 9ns's # namespace here, and a command pane needs no prompt marks. client.write('/ctl', b'Shell sh\n') # From the fixture's directory: a pane on the mount has one a # shell cannot enter. client.write('/focus', f'{first}\n'.encode()) def run(line): """A command pane runs the line; answers its exit status.""" seen = client.read('/log').decode().count('\nexit ') client.write('/exec', line) deadline = time.monotonic() + 20 while time.monotonic() < deadline: drain() exits = [row for row in client.read('/log').decode().splitlines() if row.startswith('exit ')] if len(exits) > seen: return exits[-1].split()[2] time.sleep(.1) return 'timeout' got = run(f"seq -f 'Msg mnt-%04g' 0 999 > {mount}/exec\n".encode()) check(got == '0', f'a 1000-line burst written through the mount ({got!r})') log = client.read('/log').decode() said = [line.split(' ', 2)[2] for line in log.splitlines() if line.startswith('msg ') and 'mnt-' in line] check(said == ['mnt-%04d' % i for i in range(1000)], f'each line of it ran once, whole ({len(said)} said)') check('exit 127' not in log, 'no piece of a line ran as a command') scratch = int(client.read('/pane/new')) maker = "import sys; sys.stdout.write('Edit ,a\\n' + ''.join('line %05d of the block\\n' % i for i in range(2500)) + '.\\n')" check(run(f'python3 -c "{maker}" > {mount}/pane/{scratch}/ctl\n'.encode()) == '0', 'a 50 KB Edit block written through the mount') body = client.read(f'/pane/{scratch}/body').decode().splitlines() check(body == ['line %05d of the block' % i for i in range(2500)], f'it ran once, whole ({len(body)} lines)') # Forty event reads held through the mount, a follower each, and # the mount still answers the rest: 9ns keeps workers past what # the editor holds (128), so `cat layout` is not queued behind # them for ever. held = run(f"for i in $(seq 40); do cat {mount}/pane/{first}/event > /dev/null & done; " f"sleep 1; timeout 10 cat {mount}/layout > /dev/null; st=$?; kill $(jobs -p); exit $st\n".encode()) check(held == '0', f'cat layout answers beside 40 held event reads ({held!r})') client.write('/exec', b'Msg still here\n') text = ''.join(cell[0] for cell in client.screen()['cells']) check('still here' in text, 'the editor answers after all of that') finally: os.kill(pid, signal.SIGTERM) time.sleep(.2) try: os.kill(pid, signal.SIGKILL) except ProcessLookupError: pass os.waitpid(pid, 0) for path in (fixture, Path(f'/tmp/{name}.dump.zon')): path.unlink(missing_ok=True) print('selfmount: ' + ('ok' if not failures else f'{len(failures)} failed')) return 0 if not failures else 1 if __name__ == '__main__': sys.exit(run(str(Path(sys.argv[1]).resolve())))