summaryrefslogtreecommitdiff
path: root/test/ninep.py
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /test/ninep.py
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'test/ninep.py')
-rw-r--r--test/ninep.py148
1 files changed, 148 insertions, 0 deletions
diff --git a/test/ninep.py b/test/ninep.py
new file mode 100644
index 00000000..b128cc8a
--- /dev/null
+++ b/test/ninep.py
@@ -0,0 +1,148 @@
+#!/usr/bin/env python3
+import json
+import os
+import socket
+import struct
+
+
+def string(value):
+ data = value.encode() if isinstance(value, str) else value
+ return struct.pack('<H', len(data)) + data
+
+
+class Client:
+ def __init__(self, address, timeout=5, msize=8192):
+ tcp = isinstance(address, tuple)
+ family = (socket.AF_INET6 if ':' in address[0] else socket.AF_INET) if tcp else socket.AF_UNIX
+ self.socket = socket.socket(family)
+ self.socket.settimeout(timeout)
+ if tcp:
+ self.socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+ self.tag = 0
+ self.fid = 1
+ self.msize = msize
+ try:
+ self.socket.connect(address if tcp else os.fspath(address))
+ reply = self.rpc(100, struct.pack('<I', self.msize) + string('9P2000'), tag=65535)
+ self.msize = min(self.msize, struct.unpack_from('<I', reply)[0])
+ if self.msize < 256 or reply[4:] != string('9P2000'):
+ raise OSError('unsupported 9P version or message size')
+ attached = self.rpc(104, struct.pack('<II', 1, 0xffffffff) + string('pardes') + string(''))
+ if len(attached) != 13:
+ raise OSError('invalid attach qid')
+ except BaseException:
+ self.socket.close()
+ raise
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ self.socket.close()
+
+ def receive(self, count):
+ data = bytearray()
+ while len(data) < count:
+ chunk = self.socket.recv(count - len(data))
+ if not chunk:
+ raise EOFError('9P peer closed the connection')
+ data.extend(chunk)
+ return bytes(data)
+
+ def rpc(self, kind, payload, tag=None):
+ if tag is None:
+ self.tag = (self.tag + 1) % 65535
+ tag = self.tag
+ packet = struct.pack('<IBH', 7 + len(payload), kind, tag) + payload
+ if len(packet) > self.msize:
+ raise ValueError('request exceeds negotiated message size')
+ self.socket.sendall(packet)
+ size, answer, received_tag = struct.unpack('<IBH', self.receive(7))
+ if not 7 <= size <= self.msize or received_tag != tag:
+ raise OSError('invalid 9P reply header')
+ reply = self.receive(size - 7)
+ if answer == 107:
+ length = struct.unpack_from('<H', reply)[0]
+ raise OSError(reply[2:2 + length].decode(errors='replace'))
+ if answer != kind + 1:
+ raise OSError(f'expected reply {kind + 1}, received {answer}')
+ return reply
+
+ def open(self, path, mode=0):
+ names = [part for part in os.fspath(path).split('/') if part]
+ self.fid += 1
+ fid = self.fid
+ oldfid = 1
+ try:
+ for start in range(0, max(1, len(names)), 16):
+ part = names[start:start + 16]
+ reply = self.rpc(110, struct.pack('<IIH', oldfid, fid, len(part)) +
+ b''.join(string(name) for name in part))
+ walked = struct.unpack_from('<H', reply)[0]
+ if len(reply) != 2 + 13 * walked:
+ raise OSError('invalid walk qids')
+ if walked != len(part):
+ raise FileNotFoundError(path)
+ oldfid = fid
+ if len(self.rpc(112, struct.pack('<IB', fid, mode))) != 17:
+ raise OSError('invalid open reply')
+ return fid
+ except BaseException:
+ try:
+ self.close(fid)
+ except OSError:
+ pass
+ raise
+
+ def close(self, fid):
+ self.rpc(120, struct.pack('<I', fid))
+
+ 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))
+ size = struct.unpack_from('<I', reply)[0]
+ if size != len(reply) - 4 or size > count:
+ raise OSError('invalid 9P read length')
+ return reply[4:]
+
+ def read(self, path):
+ fid = self.open(path)
+ try:
+ data = bytearray()
+ while chunk := self.read_fid(fid, len(data)):
+ data.extend(chunk)
+ return bytes(data)
+ finally:
+ self.close(fid)
+
+ def screen(self):
+ return json.loads(self.read('/self/screen'))
+
+ def write(self, path, data, truncate=False):
+ fid = self.open(path, 1 | (16 if truncate else 0))
+ try:
+ offset = 0
+ while offset < len(data):
+ chunk = data[offset:offset + self.msize - 23]
+ reply = self.rpc(118, struct.pack('<IQI', fid, offset, len(chunk)) + chunk)
+ count = struct.unpack('<I', reply)[0]
+ if not 0 < count <= len(chunk):
+ raise OSError('invalid 9P write length')
+ offset += count
+ finally:
+ self.close(fid)
+
+ def list(self, path='/'):
+ data = self.read(path)
+ names = []
+ offset = 0
+ while offset < len(data):
+ size = struct.unpack_from('<H', data, offset)[0]
+ if size < 47 or offset + size + 2 > len(data):
+ raise OSError('invalid 9P directory entry')
+ length = struct.unpack_from('<H', data, offset + 41)[0]
+ if 43 + length > size + 2:
+ raise OSError('invalid directory name length')
+ names.append(data[offset + 43:offset + 43 + length].decode())
+ offset += size + 2
+ return names