aboutsummaryrefslogtreecommitdiffstats
path: root/research/experiments/008-tree-store-edit-cost/tui.py
diff options
context:
space:
mode:
Diffstat (limited to 'research/experiments/008-tree-store-edit-cost/tui.py')
-rwxr-xr-xresearch/experiments/008-tree-store-edit-cost/tui.py128
1 files changed, 128 insertions, 0 deletions
diff --git a/research/experiments/008-tree-store-edit-cost/tui.py b/research/experiments/008-tree-store-edit-cost/tui.py
new file mode 100755
index 0000000..78c6ce0
--- /dev/null
+++ b/research/experiments/008-tree-store-edit-cost/tui.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""Ctrl+S to completed frame in the real native terminal editor, per store.
+
+Same method as 006 (full draft comparison, runtime, durable store and redraw),
+with two edit shapes: replacing the final character (same size) and typing one
+new character at the end (the document grows, as ordinary typing does)."""
+import argparse
+import fcntl
+import hashlib
+import json
+import os
+from pathlib import Path
+import platform
+import pty
+import select
+import statistics
+import struct
+import subprocess
+import tempfile
+import termios
+import time
+
+
+def run(*args):
+ return subprocess.run([str(arg) for arg in args], check=True, capture_output=True, timeout=30)
+
+
+def frame(master, process):
+ output = bytearray()
+ deadline = time.monotonic() + 15
+ while time.monotonic() < deadline:
+ if select.select([master], [], [], 0.5)[0]:
+ output.extend(os.read(master, 65536))
+ if b'\x1b[?25h' in output:
+ return bytes(output)
+ if process.poll() is not None:
+ break
+ raise AssertionError((process.poll(), output[-1000:]))
+
+
+def one_session(cli, tui, root, size, edits, shape):
+ root.mkdir(parents=True)
+ store = root / 'store'
+ body = b'A' * size
+ initial = root / 'initial.bin'
+ initial.write_bytes(body)
+ assert json.loads(run(cli, '--store', store, 'edit', 'document', 0, 0, 0, initial).stdout)['revision'] == '1'
+ master, slave = pty.openpty()
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0))
+ process = subprocess.Popen([str(tui), '--store', str(store), 'document'],
+ stdin=slave, stdout=slave, stderr=slave,
+ env={**os.environ, 'LANG': 'C.UTF-8', 'LC_ALL': 'C.UTF-8'})
+ samples, expected_body = [], bytearray(body)
+ try:
+ assert b'Pure | document' in frame(master, process)
+ for index in range(edits):
+ character = b'B' if index % 2 == 0 else b'C'
+ if shape == 'replace':
+ os.write(master, b'\x7f')
+ frame(master, process)
+ expected_body[-1:] = character
+ position = size - 1
+ else:
+ expected_body += character
+ position = size + index
+ os.write(master, character)
+ frame(master, process)
+ start = time.perf_counter_ns()
+ os.write(master, b'\x13')
+ shown = frame(master, process)
+ samples.append(time.perf_counter_ns() - start)
+ assert f'Saved 1-byte edit at byte {position}'.encode() in shown, shown[-1500:]
+ assert f'revision {index + 2}'.encode() in shown, shown[-1500:]
+ os.write(master, b'\x11')
+ process.wait(timeout=8)
+ assert process.returncode == 0
+ finally:
+ if process.poll() is None:
+ process.kill()
+ process.wait(timeout=5)
+ os.close(master)
+ os.close(slave)
+ final = run(cli, '--store', store, 'show', 'document')
+ assert final.stdout == bytes(expected_body)
+ assert json.loads(final.stderr)['revision'] == str(edits + 1)
+ return {'samples_ns': samples, 'body_sha256': hashlib.sha256(final.stdout).hexdigest()}
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--store', nargs=3, action='append', metavar=('NAME', 'CLI', 'TUI'), required=True)
+ parser.add_argument('--scratch-parent', type=Path, required=True)
+ parser.add_argument('--size', type=int, default=1_048_576)
+ parser.add_argument('--rounds', type=int, default=3)
+ parser.add_argument('--edits', type=int, default=8)
+ parser.add_argument('--output', type=Path)
+ args = parser.parse_args()
+ binaries = {name: (Path(cli).resolve(), Path(tui).resolve()) for name, cli, tui in args.store}
+ parent = args.scratch_parent.resolve()
+ parent.mkdir(parents=True, exist_ok=True)
+ filesystem = run('findmnt', '-T', parent, '-no', 'SOURCE,FSTYPE').stdout.decode().strip()
+ assert 'tmpfs' not in filesystem, 'fsync is free on tmpfs'
+ raw = {shape: {name: [] for name in binaries} for shape in ('replace', 'append')}
+ with tempfile.TemporaryDirectory(prefix='pure-tree-save-', dir=parent) as directory:
+ root = Path(directory)
+ for shape in raw:
+ for round_number in range(args.rounds):
+ names = list(binaries)
+ order = names[round_number % len(names):] + names[:round_number % len(names)]
+ for name in order:
+ cli, tui = binaries[name]
+ raw[shape][name].append(one_session(cli, tui, root / f'{shape}-{round_number}-{name}',
+ args.size, args.edits, shape))
+ medians = {shape: {name: round(statistics.median(s for session in sessions for s in session['samples_ns']) / 1e6, 2)
+ for name, sessions in stores.items()} for shape, stores in raw.items()}
+ result = {'platform': platform.platform(), 'filesystem': filesystem, 'document_bytes': args.size,
+ 'rounds': args.rounds, 'edits_per_session': args.edits, 'median_ms': medians,
+ 'binaries': {name: {'cli_sha256': hashlib.sha256(cli.read_bytes()).hexdigest(),
+ 'tui_sha256': hashlib.sha256(tui.read_bytes()).hexdigest()}
+ for name, (cli, tui) in binaries.items()},
+ 'runs': raw}
+ if args.output:
+ args.output.write_text(json.dumps(result, indent=2) + '\n')
+ print(json.dumps({'filesystem': filesystem, 'median_ms': medians}))
+
+
+if __name__ == '__main__':
+ main()