From 31cb659ded4cf50af5903fc107f8c868ee3c7311 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 22 Sep 2026 11:15:46 -0300 Subject: Answer 9P on the connection's task, so a session can open its own tree The editor's loop was the only thing that could answer a 9P request, which made the editor's own syscalls through a mount of its own tree -- a Look at /mnt/9p/pardes//anything under a `9ns --mntgen` view, a Save into it -- requests only the blocked loop could serve. The name-based refusal that followed (ownMountSuffix) and the in-process routing of a mount of oneself (Client.sameSession) were patches over that, and both are gone, with the mailbox that shipped every request to the editor's thread. One rule replaces them, `pardes.turn`: the core is single-threaded, the editor's thread has the turn by default and gives it up in two kinds of gap -- while it waits for input and while a step of it is out in a host syscall -- and a cloud9 connection task takes it in those gaps to answer. `out` counts the steps that are out, from any thread: while one is, the core reads consistently but that step still holds pointers into it, so a request that would change a pane (a write, a truncation, an rmdir) is parked in the engine and retried when the turn is next given up with nothing out, and the editor's own wake waits for the count to reach zero. It is never a write of its own that a step waits on out there -- writes come from a shell performing a save between steps -- so a parked request is never the syscall's own, and making a pane or rendering a screen need not park: every yield sits before its step's mutation, so the layout and the surface are whole under it. A changing request that queued effects is answered once the editor has performed them (`echo Save > exec` returns with the file written, as acme's `put` does), and it settles the way a step does, because without that a /log reader waited for the user's next keystroke. Every host syscall on a user path has to give the turn up, not fs.zig's alone: the first end-to-end run hung in `inotify_add_watch` performing the new pane's watch effect. PDFs and images are read whole at open, so no draw goes out into the host. The core's allocator takes its fixed buffer through the lock-free interface, since a connection task allocates while the editor's thread is out in a syscall that allocates too. A Restore puts the replacement in first and releases every task waiting on the old core. cloud9 (pinned at eb1a104) parks an open, a truncating wstat, a clunk and a remove on `again`, not only reads and writes, and answers a parked job whose fid was clunked without asking the backend. Verified: test/selfmount.py runs the editor under `9ns --mntgen` and Looks at, reads and Saves its own tree through the mount; a unit test pins that a change parks while the editor is out mid-step and lands when it rests, while a read is answered in the window. 9P over the Unix socket against a tty session, same machine, Debug builds: a read of /index 278us -> 61us, a truncating body write 1184us -> 609us, exec Save 718us -> 583us; the gesture benchmark is unchanged (geometric mean 0.997 over 53 cells). Also from the reviews: a notice chip over an image or PDF pane was painted out by the picture drawn after the cells, so pictures give up the rows; in the GUI a tree-sitter context band painted over the chip, so body layers are emitted first; a message is one row of printable text, its 256-byte cut never leaves half a glyph, and one wider than its pane keeps its tail (the file name, the reason) rather than its head. Co-Authored-By: Claude Fable 5.1 --- test/fs_namespace.zig | 24 ------- test/selfmount.py | 126 +++++++++++++++++++++++++++++++++++ test/snapshots/images.golden | 40 +++++------ test/snapshots/msg.golden | 4 ++ test/snapshots/msg.snap | 12 ++++ test/snapshots/tagbottomimage.golden | 98 +++++++++++++-------------- 6 files changed, 211 insertions(+), 93 deletions(-) create mode 100644 test/selfmount.py (limited to 'test') diff --git a/test/fs_namespace.zig b/test/fs_namespace.zig index 3eba6639..ec94b509 100644 --- a/test/fs_namespace.zig +++ b/test/fs_namespace.zig @@ -98,9 +98,6 @@ test "transactional restore preserves host capabilities and advances pane identi try std.testing.expect(restored.paneBySerial(pane.serial) == null); try std.testing.expectEqual(p.screen_w, restored.screen_w); try std.testing.expectEqual(p.screen_h, restored.screen_h); - try std.testing.expectEqualStrings(p.fs.socket_path, restored.fs.socket_path); - try std.testing.expectEqualDeep(p.fs.tcp_address, restored.fs.tcp_address); - try std.testing.expectEqualDeep(p.fs.quic_address, restored.fs.quic_address); try std.testing.expect(p.panes[0] == pane); try std.testing.expectEqualStrings("before\n", pane.file.?.content); try std.testing.expectError(error.BadDumpMagic, p.restore( @@ -112,27 +109,6 @@ test "transactional restore preserves host capabilities and advances pane identi try std.testing.expectEqualStrings("/tmp/session.sock", p.fs.socket_path); } -test "same-session identity is available before an initial mounted file is opened" { - if (!filesystem.platform_has_fs) return error.SkipZigTest; - const gpa = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "file.txt", .data = "no socket required\n" }); - var buf: [4096]u8 = undefined; - const len = try tmp.dir.realPathFile(std.testing.io, "file.txt", &buf); - const path = try std.fmt.allocPrint(gpa, "/n/own/os{s}", .{buf[0..len]}); - defer gpa.free(path); - const p = try pardes.Pardes.init(gpa, .{ - .file = path, - .mounts = &.{.{ .name = "own", .dial = "/tmp/not-listening.sock" }}, - .ninep_identity = .{ .socket_path = "/tmp/not-listening.sock" }, - }); - defer p.deinit(); - try std.testing.expectEqualStrings(path, p.panes[0].?.file.?.path); - try std.testing.expectEqualStrings("no socket required\n", p.panes[0].?.file.?.content); - try std.testing.expectEqualStrings("", p.opts.ninep_identity.socket_path); -} - test "filesystem resolves OS first then virtual with explicit mounts and parent paths" { if (!filesystem.platform_has_fs) return error.SkipZigTest; const gpa = std.testing.allocator; diff --git a/test/selfmount.py b/test/selfmount.py new file mode 100644 index 00000000..cc889a93 --- /dev/null +++ b/test/selfmount.py @@ -0,0 +1,126 @@ +#!/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/ 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 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.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.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.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') + 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()))) diff --git a/test/snapshots/images.golden b/test/snapshots/images.golden index a1b78556..74b5f873 100644 --- a/test/snapshots/images.golden +++ b/test/snapshots/images.golden @@ -54,29 +54,29 @@ == style term-palette grid=150x31 |3: 0-0 d,#608269, 1-1 d,#0d1410, 2-74 #e5eee5,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, |6: 0-1 #000000,#add38a, 2-2 d,#26362d, 3-72 #becfc4,#26362d, 73-74 #dfbce5,#26362d, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, -|7: 0-0 d,#152019, 1-27 d,#0d1410, 28-28 p8,p5, 29-30 p8,p4, 31-31 p4,p8, 32-35 p8,p4, 36-37 p4,p8, 38-39 p8,p4, 40-40 p4,p8, 41-42 p8,p4, 43-43 p4,p8, 44-46 p8,p4, 47-47 p12,p8, 48-65 d,#0d1410, 66-66 d,#1d2824, 67-73 #b2c3b8,#1d2824, 74-74 d,#1d2824, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, -|8: 0-0 d,#152019, 1-27 d,#0d1410, 28-30 p8,p4, 31-33 p4,p8, 34-34 p8,p4, 35-36 p4,p8, 37-37 p8,p4, 38-41 p4,p8, 42-42 p8,p4, 43-43 p4,p8, 44-44 p8,p12, 45-46 p12,p8, 47-47 p12,p12, 48-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, -|9: 0-0 d,#152019, 1-27 d,#0d1410, 28-28 p8,p4, 29-29 p4,p8, 30-30 p8,p4, 31-31 p4,p8, 32-32 p8,p4, 33-33 p4,p8, 34-35 p8,p4, 36-37 p4,p8, 38-38 p8,p5, 39-41 p8,p4, 42-42 p4,p8, 43-44 p12,p8, 45-47 p12,p12, 48-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, -|10: 0-0 d,#152019, 1-27 d,#0d1410, 28-29 p8,p4, 30-34 p4,p8, 35-36 p8,p4, 37-38 p4,p8, 39-39 p8,p4, 40-40 p4,p8, 41-41 p12,p8, 42-47 p12,p12, 48-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, -|11: 0-0 d,#152019, 1-27 d,#0d1410, 28-28 p4,p8, 29-29 p8,p4, 30-31 p4,p8, 32-32 p8,p4, 33-33 p4,p8, 34-34 p8,p4, 35-36 p4,p8, 37-37 p8,p4, 38-38 p8,p12, 39-39 p12,p8, 40-47 p12,p12, 48-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, -|12: 0-0 d,#152019, 1-27 d,#0d1410, 28-28 p8,p4, 29-31 p4,p8, 32-35 p8,p4, 36-36 p4,p8, 37-37 p12,p8, 38-47 p12,p12, 48-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, -|13: 0-0 d,#152019, 1-27 d,#0d1410, 28-32 p8,p4, 33-33 p4,p8, 34-34 p8,p12, 35-36 p12,p8, 37-47 p12,p12, 48-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, -|14: 0-0 d,#152019, 1-27 d,#0d1410, 28-28 p8,p4, 29-29 p4,p8, 30-30 p8,p4, 31-32 p4,p8, 33-33 p12,p8, 34-34 p8,p12, 35-47 p12,p12, 48-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, -|15: 0-0 d,#152019, 1-27 d,#0d1410, 28-29 p4,p8, 30-30 p8,p12, 31-32 p12,p8, 33-47 p12,p12, 48-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, -|16: 0-0 d,#152019, 1-27 d,#0d1410, 28-28 p8,p12, 29-29 p12,p8, 30-47 p12,p12, 48-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, +|7: 0-0 d,#152019, 1-65 d,#0d1410, 66-66 d,#1d2824, 67-73 #b2c3b8,#1d2824, 74-74 d,#1d2824, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, +|8: 0-0 d,#152019, 1-28 d,#0d1410, 29-31 p8,p4, 32-33 p4,p8, 34-37 p8,p4, 38-38 p4,p8, 39-44 p8,p4, 45-45 p8,p12, 46-46 p12,p8, 47-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, +|9: 0-0 d,#152019, 1-28 d,#0d1410, 29-29 p8,p4, 30-30 p4,p8, 31-35 p8,p4, 36-36 p5,p8, 37-37 p8,p4, 38-38 p4,p8, 39-41 p8,p4, 42-42 p4,p8, 43-43 p8,p12, 44-45 p12,p8, 46-46 p12,p12, 47-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, +|10: 0-0 d,#152019, 1-28 d,#0d1410, 29-30 p8,p4, 31-32 p4,p8, 33-40 p8,p4, 41-41 p8,p12, 42-43 p12,p8, 44-46 p12,p12, 47-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, +|11: 0-0 d,#152019, 1-28 d,#0d1410, 29-31 p8,p4, 32-32 p4,p8, 33-34 p8,p4, 35-37 p4,p8, 38-38 p8,p4, 39-39 p8,p12, 40-41 p12,p8, 42-46 p12,p12, 47-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, +|12: 0-0 d,#152019, 1-28 d,#0d1410, 29-36 p8,p4, 37-37 p8,p12, 38-39 p12,p8, 40-46 p12,p12, 47-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, +|13: 0-0 d,#152019, 1-28 d,#0d1410, 29-30 p4,p8, 31-34 p8,p4, 35-35 p8,p12, 36-36 p12,p8, 37-46 p12,p12, 47-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, +|14: 0-0 d,#152019, 1-28 d,#0d1410, 29-29 p4,p8, 30-31 p8,p4, 32-32 p4,p8, 33-33 p8,p12, 34-35 p12,p8, 36-46 p12,p12, 47-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, +|15: 0-0 d,#152019, 1-28 d,#0d1410, 29-29 p4,p8, 30-30 p8,p4, 31-31 p8,p12, 32-32 p12,p8, 33-46 p12,p12, 47-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, +|16: 0-0 d,#152019, 1-28 d,#0d1410, 29-29 p8,p12, 30-30 p12,p8, 31-46 p12,p12, 47-74 d,#0d1410, 75-75 d,#608269, 76-76 d,#0d1410, 77-149 #e5eee5,#0d1410, == snap no-ascii grid=150x31 cursor=11,8 |3: ls ls |6: img petscii:off palette:terminal ascii:off /tmp/pardes-snap/images/cwd/p -|7: ███╱█🬻██🬺██🬻███╱██🬴▄ Ascii -|8: 🬬██╱███╱🬻🬝🬻🬬████🬝🬵█ -|9: █╲█🬴████╲╱█🬝██▟▄█ -|10: ██╲╱╲█🬝█████╱🬵 -|11: ╱█████████🬝▄ -|12: █│█████🬝🬝🬹 -|13: ██████🬝▄█ -|14: 🬜🬬██🬸▄╲ -|15: 🬺█🬝🬵█ -|16: 🬝▄ +|7: Ascii +|8: █🬝█╲███▙🬸🬬🬻█████🬝🬵 +|9: ███████🬻██🬸█🬝█🬝▄█ +|10: ████████████🬝▄█ +|11: ███🬸██████🬝▄█ +|12: ████████🬝🬵█ +|13: ╱█████🬝▄ +|14: █🬬█╲🬝🬵█ +|15: 🬺█🬝🬵 +|16: 🬝▄ == snap tag-edit grid=400x31 cursor=87,6 |Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill | New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol diff --git a/test/snapshots/msg.golden b/test/snapshots/msg.golden index 653e9772..1d5b4cc2 100644 --- a/test/snapshots/msg.golden +++ b/test/snapshots/msg.golden @@ -48,3 +48,7 @@ |2: /tmp/pardes-snap/msg/cwd/a.txt * Save Tty Collapse Del |3: 1 AGAIN hand-written report |5: 3 Msg hand-written report +== snap msgtail grid=70x17 cursor=32,7 +|3: 6789 0123456789 0123456789 0123456789 0123456789 0123456789 THE-END +|6: 4 Msg 0123456789 0123456789 0123456789 0123456789 0123456789 012↩ +|7: 3456789 0123456789 THE-END diff --git a/test/snapshots/msg.snap b/test/snapshots/msg.snap index c8a0a265..5252508a 100644 --- a/test/snapshots/msg.snap +++ b/test/snapshots/msg.snap @@ -89,3 +89,15 @@ key x key tab stable 700 15000 snap msgbuiltin +# A message wider than the pane keeps its TAIL, not its head: the end of a +# message is the part that says something -- a path's file name, an error's +# reason -- so the head is what a narrow pane goes without. +key j +key o +text Msg 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 0123456789 THE-END +key esc +settle 100 +key x +key tab +stable 700 15000 +snap msgtail diff --git a/test/snapshots/tagbottomimage.golden b/test/snapshots/tagbottomimage.golden index e7100114..0136e1a0 100644 --- a/test/snapshots/tagbottomimage.golden +++ b/test/snapshots/tagbottomimage.golden @@ -35,30 +35,30 @@ |3: pic.ppm |4: |5: /tmp/pardes-snap/tagbottomimage/cwd Tty Save Mode Filter Collapse Del -|6: 4*🬹~F~N🬰|G🬏▚Y~KF🬴s>T&zZ/🬻iTY=`S~╳bkZR37v🬦🬵d4QHZ8X▛▟ -|8: ^🬪🬛$}DrydNU&wsz=`*r🬒🬓🬳🬟*Y🬴G6🬧f🬮/>t🬧u🬲1Rg1t🬝🬻█ -|9: ▖#_{🬗X@🬢&$s'C%*=w@🬆▀wq%🬡d┼a┼m─>🬭🬜A▀A🬲H]p🬝🬹 -|10: %?WX🬕f🬩*TA8n╱┼E▘🬭Y#qo~XVE▐(╳▀H4^[mj🬗=🬒🬕▟█ -|11: ]Z🬷eb~Oyl3=SL▙a🬱🬉~MPMz}FMu<🬝▚>3&▖🬷WS🬝~█ -|12: EF-🬑y🬔!r🬙🬐╳O🬩▐%Qj─4za/?/\a🬜"🬖🬈🬛🬟A🬙🬝▄ -|13: 🬃N@xzOnczIK=t0@🬖j5🬮🬧65▟{🬮🬉🬄🬩zQ9/🬝🬻 -|14: ╲${&%@"#ctt▀Cr5F;🬐ARM╱uej╳75🬹9▛🬻 -|15: h^🬜"🬈🬨▜╲|W🬊🬦k6^🬰🬶rZ─▄\z9🬎🬉4^🬎🬻 -|16: 🬆f│Q_V🬗wGx🬱=5▛5*&V🬟🬄ZrXGz2🬝🬻█ -|17: !🬷W🬐(🬭]7x_>▗er🬯~▚di🬬4jY:🬝▟ -|18: gU~🬧3▙bjr\C#kKMpffAjps🬝🬻 -|19: *🬯G"🬣▟d🬮▀zJ🬪EHV▟╳9}y▛▄ -|20: ``))~RyJ🬴jA~w=hLpX🬝🬻█ -|21: 4x▀J"TG7=y-QZ🬙╲~▛🬻 -|22: 🬯/+╳f9ZG🬕+{QN_🬝🬹 -|23: E<'─🬫pS!"n4&🬝~ -|24: m$dx"!2🬳&{🬝🬻 -|25: *8f🬩▙Jb🬒🬝🬻█ -|26: m🬩7CiN🬎🬹 -|27: pG╲L🬝🬻 -|28: 🬡L🬝▄ -|29: 🬝~ +|6: Tagbottom +|7: {DbgY🬲s▜M^#▐FUJ[zNq=🬝g/y╱?V@🬱🬙▌UK╱🬍("🬕KJ8hSo🬝🬻 +|8: tX🬆s>🬌`🬵