aboutsummaryrefslogtreecommitdiffstats
path: root/research/experiments/008-tree-store-edit-cost/tui.py
blob: 78c6ce0dd2782c71232fe3c1acebde1a738ca3fe (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
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()