summaryrefslogtreecommitdiff
path: root/test/fs.py
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-21 20:07:43 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit9070942b29bd10dddcdecdb0e88ba0fb40608467 (patch)
treefa1fc5984c7847c52bf4e78d277586edce2d8325 /test/fs.py
parent0122e94fb37422085325f2dc78ca015051ce5f91 (diff)
downloadpardes-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.py134
1 files changed, 82 insertions, 52 deletions
diff --git a/test/fs.py b/test/fs.py
index 819a78d6..c3ffa440 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -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)