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 | |
| 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')
| -rw-r--r-- | test/appearance_gallery.py | 14 | ||||
| -rw-r--r-- | test/column_tags.py | 4 | ||||
| -rw-r--r-- | test/e2e_harness.zig | 2 | ||||
| -rw-r--r-- | test/font_size.py | 6 | ||||
| -rw-r--r-- | test/fs.py | 134 | ||||
| -rw-r--r-- | test/fs_bench.zig | 4 | ||||
| -rw-r--r-- | test/fs_namespace.zig | 15 | ||||
| -rw-r--r-- | test/fs_soak.py | 8 | ||||
| -rw-r--r-- | test/look_placement.py | 6 | ||||
| -rw-r--r-- | test/ninep.py | 19 | ||||
| -rw-r--r-- | test/output.zig | 32 | ||||
| -rw-r--r-- | test/panes.zig | 76 | ||||
| -rw-r--r-- | test/pdf.zig | 2 | ||||
| -rw-r--r-- | test/tag_cleanup.py | 2 | ||||
| -rw-r--r-- | test/ui_review.py | 12 | ||||
| -rw-r--r-- | test/v9fs.py | 35 | ||||
| -rw-r--r-- | test/window_opacity.py | 2 |
17 files changed, 250 insertions, 123 deletions
diff --git a/test/appearance_gallery.py b/test/appearance_gallery.py index 0fa12136..48c0ea1b 100644 --- a/test/appearance_gallery.py +++ b/test/appearance_gallery.py @@ -82,7 +82,7 @@ def run(args): right = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'right column missing') client.write(f'/pane/{right}/name', f'{root / "Review"}\n'.encode()) client.write(f'/pane/{right}/body', PREFERENCE_NOTES if args.theme_selector else NOTES, truncate=True) - client.write(f'/pane/{right}/ctl', b'clean\n') + client.write(f'/pane/{right}/dirty', b'0\n') before = set(panes(client)) command(client, right, 'Tty') terminal = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'terminal missing') @@ -94,12 +94,12 @@ def run(args): command(client, right, 'Config') pane = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'Config output missing') text = client.read(f'/pane/{pane}/body').decode() - client.write(f'/pane/{pane}/ctl', b'exec Del\n') + client.write(f'/pane/{pane}/exec', b'Del\n') return text settings = config_report() - require('Font effective size: 18.00 pixels' in settings, 'configured font size not applied: ' + settings) - require('TaglineSize: 80' in settings, 'configured tag size missing') + require('Font effective size 18.00 pixels' in settings, 'configured font size not applied: ' + settings) + require('TaglineSize 80' in settings, 'configured tag size missing') (root / 'effective-config.txt').write_text(settings) report['font_effective_pixels'] = 18 report['tagline_percent'] = 80 @@ -135,7 +135,7 @@ def run(args): command(client, right, 'Theme ' + theme) if args.theme_selector: # Keep both quiet active gutter ink and vivid comments in view. - client.write(f'/pane/{source}/ctl', f'look {source_path}:117\n'.encode()) + client.write(f'/pane/{source}/look', f'{source_path}:117\n'.encode()) take_frame('classic-' + theme) if args.theme_selector: frame = client.screen() @@ -171,14 +171,14 @@ def run(args): 'ThemeSel did not open at native first entry') take_frame('theme-selector') report['native_themes_first'] = True - client.write(f'/pane/{selector}/ctl', b'exec Del\n') + client.write(f'/pane/{selector}/exec', b'Del\n') command(client, terminal, 'Collapse') for pet in (() if args.skip_pets else ('cat', 'frog', 'off')): command(client, right, 'Pet ' + pet) settings = config_report() require('Pet: ' + pet + '\n' in settings, 'pet setting not applied') # Returning focus to the source closes all transient report state. - client.write(f'/pane/{source}/ctl', f'look {source_path}:1\n'.encode()) + client.write(f'/pane/{source}/look', f'{source_path}:1\n'.encode()) take_frame('pet-' + pet) if pet != 'off': frame = client.screen() diff --git a/test/column_tags.py b/test/column_tags.py index 80b623bd..fe3a8699 100644 --- a/test/column_tags.py +++ b/test/column_tags.py @@ -143,7 +143,7 @@ def run(args): client.write(f'/pane/{right}/name', f'{right_name}\n'.encode()) right_body = b'// Column-local scratch\nfn main() {\n let ready = true;\n}\n' client.write(f'/pane/{right}/body', right_body) - client.write(f'/pane/{right}/ctl', b'clean\n') + client.write(f'/pane/{right}/dirty', b'0\n') require(wait_for(lambda: locate(client, 'New Tty', row=1, minimum_col=75)), 'right column tag missing') require(locate(client, 'New Tty', row=1, minimum_col=75) == (75, 1), 'right column tag retains an inset') @@ -173,7 +173,7 @@ def run(args): extra = require(wait_for(lambda: new_serial(client, before)), 'right-column New did not create a pane') client.write(f'/pane/{extra}/body', b'RIGHT-COLUMN-NEW\n') require(wait_for(lambda: locate(client, 'RIGHT-COLUMN-NEW', minimum_col=75)), 'right-column command targeted another column') - client.write(f'/pane/{extra}/ctl', b'exec Del\n') + client.write(f'/pane/{extra}/exec', b'Del\n') checks.append('middle-click New targets the inactive right column') # An owned short-path file makes its complete tag visible. Pane-tag diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig index f7f380df..5a181b78 100644 --- a/test/e2e_harness.zig +++ b/test/e2e_harness.zig @@ -92,7 +92,7 @@ pub const Harness = struct { const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid == 0) { - for ([_][*:0]const u8{ "PARDES_9P", "PARDES_PANE", "PARDES_FORWARD_LOOK" }) |name| + for ([_][*:0]const u8{ "PARDES_PID", "PARDES_9P", "PARDES_PANE" }) |name| if (unsetenv(name) != 0) _exit(126); _ = execvp(exe, argv.ptr); _exit(127); diff --git a/test/font_size.py b/test/font_size.py index 3a0a9a28..b7104099 100644 --- a/test/font_size.py +++ b/test/font_size.py @@ -30,16 +30,16 @@ def run(args): for serial, tag in panes(client).items(): if '/+Config ' in tag: body = client.read(f'/pane/{serial}/body').decode() - if f'Font effective size: {size:.2f} pixels' in body: + if f'Font effective size {size:.2f} pixels' in body: return body return None return require(wait_for(ready), f'Font did not become {size} pixels') initial = report(18) - require('Font requested size: 18.00' in initial, 'startup size suffix ignored') + require('Font requested size 18.00' in initial, 'startup size suffix ignored') require(args.font in initial, 'requested font missing') execute(client, control, f'Font {args.font}:20.5') - require('Font requested size: 20.50' in report(20.5), 'fractional size ignored') + require('Font requested size 20.50' in report(20.5), 'fractional size ignored') execute(client, control, f'Font {args.font}') report(20.5) # no suffix preserves current size execute(client, control, f'Font {args.font}:900') @@ -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) diff --git a/test/fs_bench.zig b/test/fs_bench.zig index f9735ee2..74b7b5da 100644 --- a/test/fs_bench.zig +++ b/test/fs_bench.zig @@ -132,8 +132,8 @@ fn checkReply(s: *Session, req: ctlfs.Req, reply: ctlfs.Reply) !void { const bytes = s.core.fs.out.items; if (reply.payload != .staged or reply.payload.staged != bytes.len or bytes.len > req.size) return error.IncorrectReply; - const names = [_][]const u8{ "README", "index", "ctl", "new", "log", "screen", "listeners", "pane", "os" }; - const nodes = [_]u64{ @intFromEnum(ctlfs.TopFile.README), @intFromEnum(ctlfs.TopFile.index), @intFromEnum(ctlfs.TopFile.ctl), @intFromEnum(ctlfs.TopFile.new), @intFromEnum(ctlfs.TopFile.log), @intFromEnum(ctlfs.TopFile.screen), @intFromEnum(ctlfs.TopFile.listeners), @intFromEnum(ctlfs.TopFile.pane), filesystem.os_root }; + const names = [_][]const u8{ "README", "index", "status", "look", "exec", "log", "screen", "listeners", "pane", "os" }; + const nodes = [_]u64{ @intFromEnum(ctlfs.TopFile.README), @intFromEnum(ctlfs.TopFile.index), @intFromEnum(ctlfs.TopFile.status), @intFromEnum(ctlfs.TopFile.look), @intFromEnum(ctlfs.TopFile.exec), @intFromEnum(ctlfs.TopFile.log), @intFromEnum(ctlfs.TopFile.screen), @intFromEnum(ctlfs.TopFile.listeners), @intFromEnum(ctlfs.TopFile.pane), filesystem.os_root }; var off: usize = 0; var entry: usize = 0; while (off < bytes.len) : (entry += 1) { diff --git a/test/fs_namespace.zig b/test/fs_namespace.zig index 8b6f245e..3eba6639 100644 --- a/test/fs_namespace.zig +++ b/test/fs_namespace.zig @@ -245,22 +245,17 @@ test "virtual file opens do not request OS watches" { while (p.nextEffect()) |effect| if (effect == .watch) return error.VirtualFileWatch; } -test "Look at the clone file reserves its own pane and failed opens release it" { +test "Look at a virtual file opens one pane and failed opens release their slot" { const p = try pardes.Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); - p.lookAt(0, "/virtual/new"); - var control_count: usize = 0; - var scratch_count: usize = 0; + p.lookAt(0, "/virtual/index"); + var opened: usize = 0; for (p.panes) |slot| { const pane = slot orelse continue; const file = pane.file orelse continue; - if (std.mem.eql(u8, file.path, "/virtual/new")) control_count += 1; - if (file.output) |output| if (output.from == .cmd and output.from.cmd == .New) { - scratch_count += 1; - }; + if (std.mem.eql(u8, file.path, "/virtual/index")) opened += 1; } - try std.testing.expectEqual(@as(usize, 1), control_count); - try std.testing.expectEqual(@as(usize, 1), scratch_count); + try std.testing.expectEqual(@as(usize, 1), opened); const free = p.freeSlot().?; try std.testing.expectError(error.FileNotFound, pardes.panes.File.open(p, free, "/n/self/does-not-exist", 0)); try std.testing.expect(p.panes[free] == null); diff --git a/test/fs_soak.py b/test/fs_soak.py index 5569a7c7..a62f03e6 100644 --- a/test/fs_soak.py +++ b/test/fs_soak.py @@ -145,7 +145,7 @@ def run(args): client.write(base + '/body', body, truncate=True) path = root / f'pane-{slot}-0.zig' client.write(base + '/name', f'{path}\n'.encode()) - client.write(base + '/ctl', b'exec Save\n') + client.write(base + '/exec', b'Save\n') panes.append({'serial': serial, 'body': body, 'path': path, 'variant': 0}) emit(kind='start', seed=args.seed, session_pid=pid, transport=args.transport, host='tty' if args.tty else 'detached', @@ -187,7 +187,7 @@ def run(args): pane['variant'] ^= 1 pane['path'] = root / f'pane-{slot}-{pane["variant"]}.zig' client.write(base + '/name', f'{pane["path"]}\n'.encode()) - client.write(base + '/ctl', b'exec Save\n') + client.write(base + '/exec', b'Save\n') check_bytes(pane['path'].read_bytes(), pane['body'], 'saved file') check_bytes(client.read('/os' + str(pane['path'])), pane['body'], 'OS mount') elif operation == 'reload': @@ -196,13 +196,13 @@ def run(args): client.write(base + '/ctl', b'get\n') elif operation == 'cycle': old_serial = pane['serial'] - client.write(base + '/ctl', b'exec Del\n') + client.remove(base) pane['body'] = payload(rng) pane['serial'] = new_pane(client, pane['body']) assert pane['serial'] != old_serial base = f'/pane/{pane["serial"]}' client.write(base + '/name', f'{pane["path"]}\n'.encode()) - client.write(base + '/ctl', b'exec Save\n') + client.write(base + '/exec', b'Save\n') elif operation == 'reconnect': client.socket.close() client = Client(address) diff --git a/test/look_placement.py b/test/look_placement.py index 1341e776..8a31157f 100644 --- a/test/look_placement.py +++ b/test/look_placement.py @@ -74,7 +74,7 @@ def run_case(args, root, source_index): require('pty' in client.list(pane), 'fixture contains an existing document') marker = f'LP_{index}' markers[serial] = marker - client.write(pane + '/ctl', b'cleartag\n') + client.write(pane + '/tag', b'', truncate=True) client.write(pane + '/tag', f' {marker} Tty Del Collapse'.encode()) # Establish non-pristine shells and a stable prompt before measuring. client.write(pane + '/pty/data', f"printf 'ready-{index}\\n'\r".encode()) @@ -89,7 +89,7 @@ def run_case(args, root, source_index): require(before[serials[0]][:2] == (0, 2) and before[serials[1]][0] == 0 and before[serials[1]][1] > 2 and before[serials[2]][:2] == (75, 2), f'unexpected initial classic layout: {before}') - client.write(f'/pane/{source}/ctl', f'look {target}\n'.encode()) + client.write(f'/pane/{source}/look', f'{target}\n'.encode()) opened = require(wait_for(lambda: next((serial for serial in panes(client) if serial not in serials), None)), 'Look did not open the first document') @@ -113,7 +113,7 @@ def run_case(args, root, source_index): f'Look changed unrelated terminal {serial} rendered text') require(client.read(f'/pane/{opened}/body') == contents, 'opened document contents differ') - client.write(f'/pane/{source}/ctl', f'look {target}:2\n'.encode()) + client.write(f'/pane/{source}/look', f'{target}:2\n'.encode()) require(list(panes(client)) == serials + [opened], 'repeated Look created a duplicate pane') require(wait_for(lambda: geometry(client, markers)[1] == after), 'repeated Look changed layout') diff --git a/test/ninep.py b/test/ninep.py index 6c625951..ac4e146b 100644 --- a/test/ninep.py +++ b/test/ninep.py @@ -97,6 +97,25 @@ class Client: def close(self, fid): self.rpc(120, struct.pack('<I', fid)) + def create(self, path, name, perm=0o777, mode=0): + """Tcreate `name` in the directory `path`; returns the new qid.""" + fid = self.walk(path) + try: + reply = self.rpc(114, struct.pack('<I', fid) + string(name) + + struct.pack('<IB', perm, mode)) + if len(reply) != 17: + raise OSError('invalid create reply') + return reply[:13] + finally: + self.close(fid) + + def mkdir(self, path, name): + return self.create(path, name, 0x80000000 | 0o777) + + def remove(self, path): + """Tremove, which clunks the fid whether or not it succeeds.""" + self.rpc(122, struct.pack('<I', self.walk(path))) + def read_fid(self, fid, offset=0, count=None): count = min(count if count is not None else self.msize - 11, self.msize - 11) reply = self.rpc(116, struct.pack('<IQI', fid, offset, count)) diff --git a/test/output.zig b/test/output.zig index 07a1ce23..1fcb6104 100644 --- a/test/output.zig +++ b/test/output.zig @@ -543,10 +543,10 @@ test "Config prints the startup path and refreshes its one output" { const out = p.panes[opened].?.file.?; for ([_][]const u8{ "Startup config: " ++ path ++ "\n", - "Theme: orchard\n", - "Shell requested (new panes): " ++ config.default_shell ++ " (default)\n", - "Shell effective (last spawn): (none)\n", - "Shell pending: on\n", + "Theme orchard\n", + "Shell requested (new panes) " ++ config.default_shell ++ " (default)\n", + "Shell effective (last spawn) (none)\n", + "Shell pending on\n", }) |line| try std.testing.expect(std.mem.indexOf(u8, out.content, line) != null); try std.testing.expectEqualStrings(config.config_buffer, std.fs.path.basename(out.path)); try std.testing.expectEqual(panes.Output.Origin{ .cmd = .Config }, out.output.?.from); @@ -653,36 +653,36 @@ test "every enabled setting builtin mutates the State Config reports" { try std.testing.expect(p.executeBuiltinLine(p.active, "Config")); const report = p.panes[p.active].?.file.?.content; for ([_][]const u8{ - "Colors: off\n", - "Wrap: off\n", - "Tagbottom: on\n", - "Debug: on\n", - "Theme: acme\n", - "Shell requested (new panes): fish\n", + "Colors off\n", + "Wrap off\n", + "Tagbottom on\n", + "Debug on\n", + "Theme acme\n", + "Shell requested (new panes) fish\n", }) |line| try std.testing.expect(std.mem.indexOf(u8, report, line) != null); const transition = if (builtins.capabilities.panel_transitions) - try std.fmt.bufPrint(&buf, "Panel transition: {s}\n", .{ + try std.fmt.bufPrint(&buf, "Panel transition {s}\n", .{ config.Runtime.findAction(.{ .transition = p.settings.panel_transition }).?.word, }) else - "Panel transition: unsupported\n"; + "Panel transition unsupported\n"; try std.testing.expect(std.mem.indexOf(u8, report, transition) != null); const scene_status = if (builtins.capabilities.scene_shaders) "on" else "unsupported"; for ([_][]const u8{ "Crt", "Ripple", "Glitch" }) |name| { - const line = try std.fmt.bufPrint(&buf, "{s}: {s}\n", .{ name, scene_status }); + const line = try std.fmt.bufPrint(&buf, "{s} {s}\n", .{ name, scene_status }); try std.testing.expect(std.mem.indexOf(u8, report, line) != null); } const tagline = if (!builtins.capabilities.tagline_font_size) - "TaglineSize: unsupported\n" + "TaglineSize unsupported\n" else - try std.fmt.bufPrint(&buf, "TaglineSize: {d}%{s}\n", .{ + try std.fmt.bufPrint(&buf, "TaglineSize {d}{s}\n", .{ p.settings.font.tagline_percent, if (builtins.capabilities.font_picker) "" else " (build-time only)", }); try std.testing.expect(std.mem.indexOf(u8, report, tagline) != null); if (chosen_font) |name| { try std.testing.expect(std.mem.indexOf(u8, report, name) != null); - try std.testing.expect(std.mem.indexOf(u8, report, "Font pending: on\n") != null); + try std.testing.expect(std.mem.indexOf(u8, report, "Font pending on\n") != null); } } diff --git a/test/panes.zig b/test/panes.zig index d3c29856..e4e677e4 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -731,6 +731,72 @@ const TtySelectionTests = struct { } } } + + /// Every byte the pane's program would have read off its pty since the + /// last call, joined. + fn childInput(p: *Pardes, buf: []u8) []const u8 { + var n: usize = 0; + while (p.nextEffect()) |effect| switch (effect) { + .write => |w| { + const bytes = w.bytes.slice(); + if (n + bytes.len > buf.len) break; + @memcpy(buf[n..][0..bytes.len], bytes); + n += bytes.len; + }, + else => {}, + }; + return buf[0..n]; + } + + // Raw tty mode forwards nearly every key to the child; the two paste + // chords are the exception, and forwarding them instead is what broke + // pasting into a terminal pane. An application that gets Ctrl-V rather + // than the text goes and reads the desktop clipboard on its own account, + // and a coding agent reading it is looking for an image, not for words. + test "raw tty paste chords type at the program instead of reaching it as keys" { + if (comptime !panes.Terminal.enabled) return error.SkipZigTest; + for ([_]bool{ false, true }) |bracketed| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 12 }); + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); + if (bracketed) p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?2004h" } }); + try std.testing.expectEqual(bracketed, panes.Terminal.bracketedPaste(pane)); + p.yank = try p.gpa.dupe(u8, "one\ntwo"); // what a `y` anywhere left behind + var buf: [256]u8 = undefined; + _ = childInput(p, &buf); + + // Ctrl-V types the register straight at the program, asking the + // desktop for nothing. Under DECSET 2004 the program's own fences + // carry it, which is what stops readline RUNNING a multi-line paste. + p.update(.{ .key = .{ .cp = 'v', .ctrl = true } }); + try std.testing.expectEqualStrings( + if (bracketed) "\x1b[200~one\ntwo\x1b[201~" else "one\rtwo", + childInput(p, &buf), + ); + try std.testing.expect(p.clip_pending == null); + + // Ctrl-Shift-V is the other store: it ASKS, types nothing yet, and + // the answer lands at the program rather than in an edit buffer. A + // host that folds the Shift into the letter says the same thing. + for ([_]Key{ + .{ .cp = 'v', .ctrl = true, .shift = true }, + .{ .cp = 'V', .ctrl = true }, + }) |chord| { + p.update(.{ .key = chord }); + try std.testing.expect(p.clip_pending != null); + try std.testing.expectEqualStrings("", childInput(p, &buf)); + p.update(.{ .paste = "from-desktop" }); + try std.testing.expect(p.clip_pending == null); + try std.testing.expectEqualStrings( + if (bracketed) "\x1b[200~from-desktop\x1b[201~" else "from-desktop", + childInput(p, &buf), + ); + } + // ...and neither chord put the clipboard in the register on its way. + try std.testing.expectEqualStrings("one\ntwo", p.yank orelse ""); + } + } }; const ClickCursorTests = struct { @@ -1616,7 +1682,7 @@ test "generated output refuses every pipe prompt before seeding the tag" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); const content = try p.gpa.dupe(u8, "generated\n"); - const pane = try panes.Output.open(p, 1, "/", .{ .cmd = .Help }, "", content); + const pane = try panes.Output.open(p, p.freeSlot().?, "/", .{ .cmd = .Help }, "", content); p.active = 1; for ([_]Key{ .{ .cp = '|' }, .{ .cp = '|', .alt = true }, .{ .cp = '!' }, .{ .cp = '!', .alt = true } }) |key| { p.update(.{ .key = key }); @@ -1825,14 +1891,14 @@ test "file history construction failures preserve existing panes" { defer if (incoming) |bytes| p.gpa.free(bytes); failing.fail_index = failing.alloc_index + failure; const result = switch (case) { - .open => panes.File.open(p, 1, path, 0), + .open => panes.File.open(p, p.freeSlot().?, path, 0), .restore => panes.File.restore(p, 1, .{ .kind = .file, .tag = "", .body = "", .file = .{ .path = "/restored.zig", .content = "restored\n" }, }), - .output => panes.Output.open(p, 1, "/", .{ .cmd = .Help }, "", incoming.?), + .output => panes.Output.open(p, p.freeSlot().?, "/", .{ .cmd = .Help }, "", incoming.?), .tutor => p.openTutorView(1), .replace => p.setTestFile("replacement\n"), }; @@ -1900,14 +1966,14 @@ test "file history owns bounded snapshot storage without per-edit bookkeeping al try std.testing.expectEqual(@as(usize, 0), history.redo_len); } -test "file history allocation precedes a filesystem read with side effects" { +test "file history allocation precedes reading the served tree" { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const p = try Pardes.init(failing.allocator(), .{ .tty_only = true, .cols = 40, .rows = 12 }); defer p.deinit(); const original = p.panes[0].?; const serial = p.next_serial; failing.fail_index = failing.alloc_index + 2; - try std.testing.expectError(error.OutOfMemory, panes.File.open(p, 1, "/n/self/new", 0)); + try std.testing.expectError(error.OutOfMemory, panes.File.open(p, p.freeSlot().?, "/n/self/index", 0)); try std.testing.expect(failing.has_induced_failure); try std.testing.expectEqual(original, p.panes[0].?); try std.testing.expectEqual(serial, p.next_serial); diff --git a/test/pdf.zig b/test/pdf.zig index beeb0b40..930cc5c0 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -1253,7 +1253,7 @@ test "PdfTint cycles pane-local state without adding tag clutter" { try std.testing.expectEqual(PdfTintMode.filtered, pv.tint); const initial_tag = try pardes.test_api.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.startsWith(u8, initial_tag, pv.path)); - try std.testing.expect(std.mem.endsWith(u8, initial_tag, " PdfSections PdfTint Collapse")); + try std.testing.expect(std.mem.endsWith(u8, initial_tag, " PdfSections PdfTint Collapse Del")); try std.testing.expect(std.mem.indexOf(u8, initial_tag, "filtered") == null); try std.testing.expect(pardes.test_api.runBuiltin( diff --git a/test/tag_cleanup.py b/test/tag_cleanup.py index 7b7d8bbf..ae0a0e66 100644 --- a/test/tag_cleanup.py +++ b/test/tag_cleanup.py @@ -36,7 +36,7 @@ def run(args): before = set(panes(client)) execute(client, control, 'Tty') tty = require(wait_for(lambda: next((s for s in panes(client) if s not in before), None)), 'terminal missing') - client.write(f'/pane/{control}/ctl', b'exec Del\n') # owned command-only scratch + client.write(f'/pane/{control}/exec', b'Del\n') # owned command-only scratch def tag_position(): frame = client.screen() diff --git a/test/ui_review.py b/test/ui_review.py index 2b6faa29..a1cdcdeb 100644 --- a/test/ui_review.py +++ b/test/ui_review.py @@ -43,7 +43,7 @@ def command(client, serial, text): current = client.read(tag) start = current.rfind(text.encode()) client.write(f'/pane/{serial}/event', f'Mx{start} {start + len(text.encode())}\n'.encode()) - client.write(f'/pane/{serial}/ctl', b'cleartag\n') + client.write(f'/pane/{serial}/tag', b'', truncate=True) client.write(tag, before[tail_start:]) def new_output(client, before): @@ -111,7 +111,7 @@ def run(args): capture(client, root, '02-find', not args.tty) if find_pane: (root / 'find-results.txt').write_bytes(client.read(f'/pane/{find_pane}/body')) - client.write(f'/pane/{find_pane}/ctl', b'exec Del\n') + client.write(f'/pane/{find_pane}/exec', b'Del\n') before = panes(client) command(client, source, 'Grep CrdsValueLabel') grep_pane = require(wait_for(lambda: new_output(client, before)), 'Grep did not return results') @@ -140,10 +140,10 @@ def run(args): (root / 'symbols-results.txt').write_bytes(client.read(f'/pane/{symbols}/body')) capture(client, root, '05-symbols', not args.tty) if symbols: - client.write(f'/pane/{symbols}/ctl', b'exec Del\n') + client.write(f'/pane/{symbols}/exec', b'Del\n') offset = original.index(b'CrdsValue {') client.write(f'/pane/{source}/addr', f'#{offset},#{offset}'.encode()) - client.write(f'/pane/{source}/ctl', b'dot=addr\nshow\n') + client.write(f'/pane/{source}/dot', client.read(f'/pane/{source}/addr')) before = panes(client) command(client, source, 'Hover') hover = wait_for(lambda: new_output(client, before), args.lsp_wait) @@ -154,7 +154,7 @@ def run(args): # Look focuses the source without changing it; gd then travels through # the host's real input parser, unlike tag builtin dispatch above. line = original[:original.index(b'impl Sanitize for CrdsValue')].count(b'\n') + 1 - client.write(f'/pane/{source}/ctl', f'look {source_path}:{line}:19\n'.encode()) + client.write(f'/pane/{source}/look', f'{source_path}:{line}:19\n'.encode()) os.write(terminal_handle['input_fd'], b'gd') time.sleep(1) capture(client, root, '07-definition', not args.tty) @@ -166,7 +166,7 @@ def run(args): report['lsp'] = info.decode() capture(client, root, '08-lsp-info', not args.tty) if lsp: - client.write(f'/pane/{lsp}/ctl', b'exec Del\n') + client.write(f'/pane/{lsp}/exec', b'Del\n') for theme in args.themes: command(client, source, 'Theme ' + theme) capture(client, root, 'theme-' + theme, not args.tty) diff --git a/test/v9fs.py b/test/v9fs.py index 16916d0b..fa35388f 100644 --- a/test/v9fs.py +++ b/test/v9fs.py @@ -43,8 +43,8 @@ def worker(mountpoint, socket, uid, gid, original_namespace): assert not any(field.startswith(('shared:', 'master:')) for field in mounted[0].split()[6:]) tree = mountpoint - assert {'os', 'index', 'pane', 'new', 'ctl', 'log', 'screen', 'README'} <= set(os.listdir(tree)) - assert 'self' not in os.listdir(tree) + assert {'os', 'index', 'pane', 'status', 'look', 'exec', 'log', 'screen', 'README'} <= set(os.listdir(tree)) + assert 'self' not in os.listdir(tree) and 'new' not in os.listdir(tree) # A direct connection provides independent evidence for VFS reads/writes. with Client(socket) as client: assert (tree / 'index').read_bytes() == client.read('/index') @@ -52,14 +52,31 @@ def worker(mountpoint, socket, uid, gid, original_namespace): before = client.read('/index') subprocess.run(['ls', '-l', str(tree), str(tree / 'pane' / '1')], check=True, capture_output=True, timeout=5) - subprocess.run(['find', str(tree / 'pane'), str(tree / 'new'), '-ls'], check=True, capture_output=True, timeout=5) + subprocess.run(['find', str(tree / 'pane'), '-ls'], check=True, capture_output=True, timeout=5) assert (tree / 'README').read_bytes() == client.read('/README') - assert (tree / 'new').stat().st_size == 0 assert client.read('/index') == before, 'browsing created panes' - serial = int((tree / 'new').read_bytes().split()[0]) - another = int((tree / 'new').read_bytes().split()[0]) - assert serial != another, 'cached factory reused a pane' - client.write(f'/pane/{another}/ctl', b'exec Del\n') + # mkdir through the kernel mount opens a pane. The editor names it + # after its serial, not after the name asked for, so the kernel's own + # revalidation of that name may fail; the index is the answer. + def serials(): + return {int(row.split()[0]) for row in (tree / 'index').read_bytes().splitlines()} + + def mkpane(name): + known = serials() + try: + (tree / 'pane' / name).mkdir() + except FileNotFoundError: + pass + made = serials() - known + assert len(made) == 1, (name, made) + return made.pop() + + serial = mkpane('kernel-made') + another = mkpane('kernel-made-again') + assert serial != another, 'a second mkdir reused a pane' + assert not (tree / 'pane' / 'kernel-made').exists() + (tree / 'pane' / str(another)).rmdir() + assert another not in serials() pane = tree / 'pane' / str(serial) wire = f'/pane/{serial}' assert str(serial) in os.listdir(tree / 'pane') @@ -101,7 +118,7 @@ def worker(mountpoint, socket, uid, gid, original_namespace): # Reading OS files through the exported tree does not recurse through # the mount: the core still lives in the supervisor's namespace. assert (mountpoint / 'os' / str(socket.parent).lstrip('/') / 'kernel.txt').read_bytes() == b'initial\n' - write_existing(pane / 'ctl', b'exec Del\n') + (tree / 'pane' / str(serial)).rmdir() assert serial not in [int(row.split()[0]) for row in (tree / 'index').read_bytes().splitlines()] print('v9fs: namespace isolation, privilege drop, inherited mount, directory refresh, ' diff --git a/test/window_opacity.py b/test/window_opacity.py index b9529373..a576b1bd 100644 --- a/test/window_opacity.py +++ b/test/window_opacity.py @@ -60,7 +60,7 @@ def run(args): pane = require(wait_for(lambda: next((serial for serial in panes(client) if serial not in before), None)), builtin + ' output missing') text = client.read(f'/pane/{pane}/body').decode() - client.write(f'/pane/{pane}/ctl', b'exec Del\n') + client.write(f'/pane/{pane}/exec', b'Del\n') return text help_text = output('Help') |
