summaryrefslogtreecommitdiff
path: root/test/selfmount.py
blob: 57dfffa812707b5661d71e8356a744d6165bf1e2 (plain) (blame)
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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
#!/usr/bin/env python3
"""A session that opens, reads and saves its own 9P tree through a mount.

The editor runs under `9ns --mntgen`, so /mnt/9p/pardes/<name> inside its
namespace is its own tree, and is driven over its socket from outside. This
used to hang the editor outright: the realpath, the stat and the read went
out through the mount and came back as 9P requests only the editor's loop
could answer, while that loop was blocked making them. Now the connection's
task answers them while the editor waits (pardes.turn).

Needs 9ns (cloud9) on PATH or in ~/.local/bin, and a runtime directory; it
skips, loudly, without them.
"""
import fcntl
import os
from pathlib import Path
import pty
import shutil
import signal
import struct
import sys
import termios
import threading
import time

sys.path.insert(0, str(Path(__file__).resolve().parent))
from ninep import Client


def run(binary):
    ns = shutil.which('9ns') or str(Path.home() / '.local/bin/9ns')
    runtime = os.environ.get('XDG_RUNTIME_DIR')
    if not os.path.exists(ns) or not runtime:
        print('selfmount: skipped (needs 9ns and XDG_RUNTIME_DIR)')
        return 0
    name = 'selfmount%d' % os.getpid()
    sock = f'{runtime}/pardes-9p-{name}.sock'
    fixture = Path(f'/tmp/{name}.txt')
    fixture.write_bytes(b'initial\n')
    env = {k: v for k, v in os.environ.items() if not k.startswith('PARDES_')}
    env.update(PARDES_NOTIME='1', TERM='xterm-256color', PARDES_DUMP=f'/tmp/{name}.dump.zon')
    for language in ['RS', 'C', 'GO', 'TS', 'PY']:
        env['PARDES_LSP_' + language] = ''
    pid, master = pty.fork()
    if pid == 0:
        os.execve(ns, ['9ns', '--mntgen', '--', binary, '--tty', '--9p=' + name, str(fixture)], env)
    fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack('HHHH', 30, 100, 0, 0))
    os.set_blocking(master, False)
    tail = bytearray()

    def drain():
        try:
            while True:
                chunk = os.read(master, 65536)
                if not chunk:
                    return
                tail.extend(chunk[-2000:])
        except OSError:
            pass

    failures = []

    def check(condition, what):
        print(('ok   ' if condition else 'FAIL ') + what)
        if not condition:
            failures.append(what)

    try:
        deadline = time.monotonic() + 15
        while not os.path.exists(sock):
            drain()
            if time.monotonic() > deadline:
                raise TimeoutError('socket never appeared: ' + tail.decode(errors='replace')[-400:])
            time.sleep(.02)
        time.sleep(.3)
        mount = f'/mnt/9p/pardes/{name}'
        with Client(sock) as client:
            index = client.read('/index').decode()
            rows = [row.rsplit(maxsplit=1)[0].split(maxsplit=3) for row in index.splitlines()]
            first = rows[0][0]
            # A Look at the tree's own index: realpath, stat and read all go
            # out through 9ns and back in, and the write returns when the
            # pane is open.
            started = time.monotonic()
            client.write('/look', (mount + '/index\n').encode())
            took = time.monotonic() - started
            check(took < 5, f'look at the mounted index returned ({took:.2f}s)')
            rows = [row.rsplit(maxsplit=1)[0].split(maxsplit=3) for row in client.read('/index').decode().splitlines()]
            opened = [row for row in rows if len(row) == 4 and row[3] == mount + '/index']
            check(len(opened) == 1, 'a pane opened on the mounted index')
            if opened:
                body = client.read(f'/pane/{opened[0][0]}/body').decode()
                check(body.startswith(index.splitlines()[0]), 'its body is the index as served')
            # A Look at pane 1's body through the mount, an edit of the copy,
            # and a Save: the save writes back through the mount into pane 1,
            # and the exec returns once it has.
            client.write('/look', f'{mount}/pane/{first}/body\n'.encode())
            rows = [row.rsplit(maxsplit=1)[0].split(maxsplit=3) for row in client.read('/index').decode().splitlines()]
            mirror = [row for row in rows if len(row) == 4 and row[3] == f'{mount}/pane/{first}/body']
            check(len(mirror) == 1, 'a pane opened on the mounted body')
            if mirror:
                pane = mirror[0][0]
                client.write(f'/pane/{pane}/addr', b'#0,#0')
                client.write(f'/pane/{pane}/data', b'HELLO ')
                client.write(f'/pane/{pane}/exec', b'Save\n')
                check(client.read(f'/pane/{first}/body') == b'HELLO initial\n', f'Save wrote back through the mount into pane {first}')
                text = ''.join(cell[0] for cell in client.screen()['cells'])
                check('saved ' + mount in text, 'the editor reported the save')
            # Through the kernel mount a write is cut at its msize: a
            # 1000-line burst and a 50 KB Edit block each still run whole,
            # line by line, and no piece of a line runs as a shell command.
            # sh: bash's and fish's prompt files are not reachable in 9ns's
            # namespace here, and a command pane needs no prompt marks.
            client.write('/ctl', b'Shell sh\n')
            # From the fixture's directory: a pane on the mount has one a
            # shell cannot enter.
            client.write('/focus', f'{first}\n'.encode())

            def run(line):
                """A command pane runs the line; answers its exit status."""
                seen = client.read('/log').decode().count('\nexit ')
                client.write('/exec', line)
                deadline = time.monotonic() + 20
                while time.monotonic() < deadline:
                    drain()
                    exits = [row for row in client.read('/log').decode().splitlines() if row.startswith('exit ')]
                    if len(exits) > seen:
                        return exits[-1].split()[2]
                    time.sleep(.1)
                return 'timeout'
            # Followed from before the burst: a fresh open of the log reads
            # only what its 64 KiB ring still holds, which a burst this size
            # outgrows, and a follower the ring outruns reads `lost N`, so
            # every line is either heard once, in order, or a loss is said.
            heard = []

            def follow():
                with Client(sock) as follower:
                    fid = follower.open('/log', 2)
                    start = len(follower.read_fid(fid))
                    follower.rpc(118, struct.pack('<IQI', fid, 0, len(b'follow new\n')) + b'follow new\n')
                    ready.set()
                    deadline = time.monotonic() + 60
                    while time.monotonic() < deadline:
                        record = follower.read_fid(fid, start).decode()
                        heard.append(record)
                        if 'mnt-0999' in record:
                            return
            ready = threading.Event()
            reader = threading.Thread(target=follow, daemon=True)
            reader.start()
            check(ready.wait(10), 'a follower of the log is waiting')
            got = run(f"seq -f 'Msg mnt-%04g' 0 999 > {mount}/exec\n".encode())
            check(got == '0', f'a 1000-line burst written through the mount ({got!r})')
            reader.join(60)
            for r in heard:
                if r.startswith(('err ', 'exit ')):
                    print('     heard: ' + r.rstrip())
            said = [r.rstrip('\n').split(' ', 2)[2] for r in heard if r.startswith('msg ') and 'mnt-' in r]
            lost = [r for r in heard if r.startswith('lost ')]
            numbers = [int(s[4:]) for s in said]
            check(numbers == list(range(numbers[0] if numbers else 0, 1000)),
                  f'each line it heard ran once, whole, in order, through the last ({len(said)} heard)')
            check(len(said) == 1000 or (lost and heard.index(lost[0]) < heard.index(next(r for r in heard if 'mnt-' in r))),
                  f'no line was lost unsaid ({len(said)} heard, {lost!r})')
            check(not any('exit 127' in r for r in heard), 'no piece of a line ran as a command')
            scratch = int(client.read('/pane/new'))
            maker = "import sys; sys.stdout.write('Edit ,a\\n' + ''.join('line %05d of the block\\n' % i for i in range(2500)) + '.\\n')"
            check(run(f'python3 -c "{maker}" > {mount}/pane/{scratch}/ctl\n'.encode()) == '0', 'a 50 KB Edit block written through the mount')
            body = client.read(f'/pane/{scratch}/body').decode().splitlines()
            check(body == ['line %05d of the block' % i for i in range(2500)], f'it ran once, whole ({len(body)} lines)')
            # One 600 KiB line: 9ns sends it as 65512-byte Twrites, the last
            # one short, and none is taken for a whole line by its size.
            errs = client.read('/log').decode().count('\nerr ')
            long_edit = "import sys; sys.stdout.write('Edit ,c/' + 'q' * 600000 + '/\\n')"
            check(run(f'python3 -c "{long_edit}" > {mount}/pane/{scratch}/ctl\n'.encode()) == '0', 'a 600 KiB Edit line written through the mount')
            check(client.read(f'/pane/{scratch}/body') == b'q' * 600000, 'it ran once, whole')
            check(client.read('/log').decode().count('\nerr ') == errs, 'no piece of it was refused as a line of its own')
            # Over 1 MiB: refused once, its tail dropped through its newline.
            too_long = "import sys; sys.stdout.write('Msg ' + 'z' * 1150000 + '\\nMsg after-long\\n')"
            run(f'python3 -c "{too_long}" > {mount}/exec\n'.encode())
            log = client.read('/log').decode()
            check(log.count('over 1 MiB') == 1 and 'unknown control message' not in log and 'after-long' in log,
                  'a 1.1 MiB line is refused once, its tail never a line, the next line runs')
            # `pardes -` by hand through the mount: the directory, then a
            # program's output, escapes and all, on one open of /pager; the
            # +Pager reads back the plain text.
            got = run(f"{{ pwd; printf '\\033[31mred\\033[m\\n'; }} > {mount}/pager\n".encode())
            rows = [row.rsplit(maxsplit=1)[0].split(maxsplit=3) for row in client.read('/index').decode().splitlines()]
            paged = [row[0] for row in rows if len(row) == 4 and row[3].endswith('/+Pager')]
            check(got == '0' and len(paged) == 1, f'a +Pager made through the mount ({got!r}, {paged!r})')
            if paged:
                # The text goes in at the open's close, and a mount's close
                # (FUSE's release) comes after close(2) has returned.
                deadline = time.monotonic() + 5
                while (said := client.read(f'/pane/{paged[0]}/body')) != b'red\n' and time.monotonic() < deadline:
                    time.sleep(.05)
                check(said == b'red\n', f'its body is the plain text ({said!r})')
            # Forty event reads held through the mount, a follower each, and
            # the mount still answers the rest: 9ns keeps workers past what
            # the editor holds (128), so `cat layout` is not queued behind
            # them for ever.
            held = run(f"for i in $(seq 40); do cat {mount}/pane/{first}/event > /dev/null & done; "
                       f"sleep 1; timeout 10 cat {mount}/layout > /dev/null; st=$?; kill $(jobs -p); exit $st\n".encode())
            check(held == '0', f'cat layout answers beside 40 held event reads ({held!r})')
            # fs.md's follow recipe, as written, with plain cat, its output
            # a pipe (where coreutils cat splices): it hears a record made
            # after it started.
            recipe = Path(f'/tmp/{name}.follow')
            ran = run(f"exec 3<>{mount}/log; echo follow >&3; "
                      f"(sleep 1; echo 'Msg recipe-heard' > {mount}/exec) & "
                      f"timeout 5 cat <&3 | cat > {recipe}; exit 0\n".encode())
            followed = recipe.read_text() if recipe.exists() else ''
            recipe.unlink(missing_ok=True)
            check(ran == '0' and 'recipe-heard' in followed, f'the log follow recipe with cat hears a new record ({followed[-120:]!r})')
            # A bash read loop on a pane's event: bash reads it a piece at a
            # time, and each record still arrives whole, in pieces.
            looped = Path(f'/tmp/{name}.events')
            ran = run((f"bash -c 'exec 3<{mount}/pane/{first}/event; "
                       f"(sleep 1; echo Msg ev-loop > {mount}/pane/{first}/exec) & "
                       f"read -r line <&3; echo \"$line\" > {looped}'\n").encode())
            got_line = looped.read_text() if looped.exists() else ''
            looped.unlink(missing_ok=True)
            check(ran == '0' and got_line.startswith('FX') and got_line.rstrip().endswith('Msg ev-loop'),
                  f'a bash read loop on event reads a whole record ({got_line!r})')
            # Past the session's 64 open records (write opens of exec take
            # them too) an open is refused EMFILE, "Too many open files".
            # (>> here: a truncating open of exec through the mount can
            # hang, reported, not yet understood.)
            many = Path(f'/tmp/{name}.many')
            ran = run((f"timeout 10 bash -c 'for i in $(seq 80); do exec {{fd}}>>{mount}/exec || exit 9; done' 2> {many}; "
                       f"st=$?; echo rc=$st >> {many}; exit $st\n").encode())
            said_many = many.read_text() if many.exists() else ''
            many.unlink(missing_ok=True)
            check(ran == '9' and 'Too many open files' in said_many, f'an open past the 64 records is EMFILE ({ran!r}, {said_many[-80:]!r})')
            client.write('/exec', b'Msg still here\n')
            text = ''.join(cell[0] for cell in client.screen()['cells'])
            check('still here' in text, 'the editor answers after all of that')
    finally:
        os.kill(pid, signal.SIGTERM)
        time.sleep(.2)
        try:
            os.kill(pid, signal.SIGKILL)
        except ProcessLookupError:
            pass
        os.waitpid(pid, 0)
        for path in (fixture, Path(f'/tmp/{name}.dump.zon')):
            path.unlink(missing_ok=True)
    print('selfmount: ' + ('ok' if not failures else f'{len(failures)} failed'))
    return 0 if not failures else 1


if __name__ == '__main__':
    sys.exit(run(str(Path(sys.argv[1]).resolve())))