diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-21 20:07:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 9070942b29bd10dddcdecdb0e88ba0fb40608467 (patch) | |
| tree | fa1fc5984c7847c52bf4e78d277586edce2d8325 /test/fs.py | |
| parent | 0122e94fb37422085325f2dc78ca015051ce5f91 (diff) | |
| download | pardes-9070942b29bd10dddcdecdb0e88ba0fb40608467.tar.gz pardes-9070942b29bd10dddcdecdb0e88ba0fb40608467.zip | |
Plan 9 idiom for the control filesystem, and the regressions a624a56 left
The 9P tree stops being a command language wearing a filesystem. /new
created a pane as a side effect of a *read*; it is now Tcreate in /pane,
with Tremove to close, which cloud9's engine has always supported and the
editor never declared: tree.zig now says
`features = .{ .create = true, .remove = true }`. Eleven pane ctl verbs
become files that can be read as well as written -- dot, limit, dirty,
mark, scroll, look, exec -- leaving ctl with `get`, the one verb no file
would say better. Root /ctl splits into a read-only /status and the
/look and /exec files whose write IS the click. stat carries real sizes
where it used to answer 0, and qid versions track a pane's revision, so a
client can poll for change without re-reading the body.
Commit a624a56 moved raw-tty keys to an early-return branch that knew only
Ctrl-B and bare Escape, and in the same edit deleted the paste branch below
it. That cost Shift-Escape (the unconditional way out of tty mode) and both
paste chords: Ctrl-V and Ctrl-Shift-V reached the child as keystrokes, so an
agent CLI running in a pane took Ctrl-V for its image-paste binding and
answered "No image found in clipboard". Both are restored, with tests.
Nested detection was not subtly broken but deleted: 60367d8 removed
nested.zig's process-ancestry walk and left "am I inside pardes" derived from
PARDES_FORWARD_LOOK, which read "0" both for --nested and for "the listener
did not come up". PARDES_PID now answers that question on its own, checked
with kill(pid, 0); PARDES_9P and PARDES_PANE answer how to reach it; the flag
is gone. The posted-9P registry also self-heals now -- a session that aborts
cannot unlink its own socket, so posting sweeps entries whose target refuses
a connection, symlinks only and on a definite ECONNREFUSED only.
Elsewhere: tty scrolling is sticky-bottom, following new output only from
the last row, with typing and entering raw mode snapping back to live; the
boot layouts are a Boot enum instead of a chain of ifs, and the bare tty
startup (Boot.tty, which main.zig names) opens an empty text pane under the
shell while tests keep Boot.tty_shell; builtins announce themselves on the
message row under a Verbose setting that is on by default; Config prints
each setting the way you would type it back, so WindowOpacity 70 rather than
"WindowOpacity: 70%"; LocationsConfig opens its window only when called bare;
every tagline puts the word that closes the thing last, and a column now
outlives its panes -- closing the last one leaves an empty pane, and only
Delcol, newly on the column tagline, takes the column away.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to 'test/fs.py')
| -rw-r--r-- | test/fs.py | 134 |
1 files changed, 82 insertions, 52 deletions
@@ -100,8 +100,15 @@ def session(binary, root, name, *options, socket_name=None, file=None, tty=False 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): - serial = int(client.read('/new').split()[0]) + """mkdir in /pane; the editor names the directory after the new serial.""" + client.mkdir('/pane', 'scratch') + serial = newest(client) if contents: client.write(f'/pane/{serial}/body', contents) return serial @@ -110,18 +117,18 @@ def new_pane(client, contents): 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}/ctl', f'look {path}\n'.encode()) - results = [int(row) for row in client.read('/ctl').split()] + 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}/ctl', f'exec {command}\n'.encode()) + client.write(f'/pane/{serial}/exec', f'{command}\n'.encode()) -def walk_tree(client, path='/', skip=('/new', '/log', '/os', '/screen')): +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): @@ -138,18 +145,21 @@ def discovery(binary, embedded=False): root = Path(directory) with session(binary, root, 'discovery') as (client, _): top = client.list('/') - assert top[:9] == ['README', 'index', 'ctl', 'new', 'log', 'screen', 'listeners', 'pane', 'os'], top - assert 'self' not in top and 'cons' not in top, top + 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'exec Msg' in guide and b'/new' in guide, guide + assert guide.count(b'\n') <= 45 and b'Msg hello' in guide and b'mkdir' 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', '/ctl', '/new', '/log', '/pane/1', '/pane/1/body', '/pane/1/name', - '/pane/1/sel', '/pane/1/ctl', '/pane/1/tag', '/pane/1/event'} <= names, sorted(names) + 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) @@ -159,18 +169,28 @@ def discovery(binary, embedded=False): 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('/ctl')['mode'] == 0o666 + 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 - # Before any command, /ctl reports the editor. - status = dict(line.split(maxsplit=1) for line in client.read('/ctl').decode().splitlines()) - assert int(status['pid']) > 0 and status['version'] and int(status['panes']) == 1, status - # /new: one pane per open, its serial is the file's content. + # /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'' + # mkdir makes a pane, named by the serial the editor gives it. log = client.open('/log') - first = int(client.read('/new').split()[0]) - second = int(client.read('/new').split()[0]) + client.mkdir('/pane', 'one') + first = newest(client) + client.mkdir('/pane', 'two') + second = newest(client) assert first != second and first != fixture, (first, second) + assert 'one' not in client.list('/pane') and 'two' not in client.list('/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')) == {str(fixture), str(first), str(second)} @@ -186,51 +206,63 @@ def discovery(binary, embedded=False): 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}/ctl', b'dot=addr\n') + 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; exec Del closes. - client.write(f'/pane/{first}/ctl', b'exec Msg hello from 9P\n') + # 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']) - client.write(f'/pane/{first}/ctl', b'exec Save\n') + 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('/ctl') == f'{first}\n'.encode() - client.write('/ctl', b'exec Del\n') + 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 through /ctl opens a file; the serial reads back. + # look opens a file; the serial reads back. (root / 'opened.txt').write_bytes(b'one\ntwo\nthree\n') - client.write('/ctl', f'look {root}/opened.txt:2\n'.encode()) - opened = int(client.read('/ctl')) + 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}/ctl', f'look {root}/opened.txt:3\n'.encode()) - assert client.read('/ctl') == f'{opened}\n'.encode(), 'a repeated look targets the open pane' - assert set(client.list('/pane')) == {str(fixture), str(first), str(opened)} + 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']) - for bad in [b'Look x\n', b'Exec Msg x\n', b'put\n', b'name x\n', b'del\n']: + # 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.create('/pane', 'plain-file'), + lambda: client.remove('/pane/1/body'), + lambda: client.remove('/index')] + for index, attempt in enumerate(refused): try: - client.write('/ctl', bad) + attempt() except OSError: pass else: - raise AssertionError(f'{bad!r} was accepted') + 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, ctl, name, sel and log behave') + print('9P discovery: listing/stat/find are inert; create, remove, look, exec, name, sel and log behave') def test(binary, quic=False): @@ -266,7 +298,7 @@ def test(binary, quic=False): 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', 'new', 'ctl', 'log', 'os'} <= set(client.list('/')) + 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' @@ -346,7 +378,7 @@ def test(binary, quic=False): 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}/ctl', b'exec Save\n') + 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 @@ -356,7 +388,7 @@ def test(binary, quic=False): 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}/ctl', b'exec Save\n') + remote.write(f'/pane/{opened}/exec', b'Save\n') assert large_path.read_bytes() == edited assert client.read('/os' + str(large_path)) == edited @@ -368,12 +400,12 @@ def test(binary, quic=False): 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}/ctl', b'exec Save\n') + 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}/ctl', b'exec Del\n') + 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' @@ -385,7 +417,7 @@ def test(binary, quic=False): 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}/ctl', b'exec Del\n') + client.write(f'/pane/{serial}/exec', b'Del\n') assert str(serial) not in client.list('/pane') for tty in [False, True]: @@ -424,7 +456,7 @@ def test(binary, quic=False): 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 ctl, not the body' + 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' @@ -451,14 +483,12 @@ def test(binary, quic=False): assert client.read(f'/pane/{opened}/body') == expected assert client.read('/pane/1/body') == b'initial\n' if selected is not None: - addr = client.open(f'/pane/{opened}/addr') - client.write(f'/pane/{opened}/ctl', b'addr=dot\n') - actual = list(map(int, client.read_fid(addr).split())) - client.close(addr) + # 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) - ctl = client.open('/pane/1/ctl', 1) - for fid, text in [(addr, b'#0,#3'), (ctl, b'limit=addr\n'), (addr, b'#0')]: + 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('<IQI', fid, 0, len(text)) + text) try: for _ in range(8): @@ -474,7 +504,7 @@ def test(binary, quic=False): raise AssertionError('no-argument child cleared the parent address limit') finally: client.close(addr) - client.close(ctl) + client.close(limit) client.read('/pane/1/ctl') control = new_pane(client, b'') @@ -618,7 +648,7 @@ def quic_test(binary): assert remote.read(body) == b'initial\n' payload = 'saved café λ\n'.encode() + b'x' * (3 * 8192 + 27) + b'\n' remote.write(body, payload, truncate=True) - remote.write(f'/pane/{opened}/ctl', b'exec Save\n') + remote.write(f'/pane/{opened}/exec', b'Save\n') assert unix.read_fid(unix_body) == payload[:unix.msize - 11] assert tcp.read('/pane/1/body') == payload tcp.write('/pane/1/body', b'TCP to QUIC Get\n', truncate=True) @@ -637,10 +667,10 @@ def quic_test(binary): assert remote.read(f'/pane/{large_pane}/body') == large edited = b'QUIC file saved\n' remote.write(f'/pane/{large_pane}/body', edited, truncate=True) - remote.write(f'/pane/{large_pane}/ctl', b'exec Save\n') + remote.write(f'/pane/{large_pane}/exec', b'Save\n') assert fixture.read_bytes() == edited for pane in [opened, screen_pane, large_pane]: - remote.write(f'/pane/{pane}/ctl', b'exec Del\n') + remote.write(f'/pane/{pane}/exec', b'Del\n') execute(remote, control, 'Unmount peer') roots = look(remote, '/n', source=control) assert remote.read(f'/pane/{roots}/body') == b'/n/os/\n/n/self/\n' @@ -651,10 +681,10 @@ def quic_test(binary): own = look(unix, '/n/own/pane/1/body', source=control) assert unix.read(f'/pane/{own}/body') == b'TCP to QUIC Get\n' unix.write(f'/pane/{own}/body', b'same-session QUIC Save\n', truncate=True) - unix.write(f'/pane/{own}/ctl', b'exec Save\n') + unix.write(f'/pane/{own}/exec', b'Save\n') assert unix.read_fid(unix_body) == b'same-session QUIC Save\n' assert tcp.read_fid(tcp_body) == b'same-session QUIC Save\n' - unix.write(f'/pane/{own}/ctl', b'exec Del\n') + unix.write(f'/pane/{own}/exec', b'Del\n') execute(unix, control, 'Unmount own') unix.close(unix_body) tcp.close(tcp_body) |
