From b1ce1927022d374198441afafeb27199fc8a052a Mon Sep 17 00:00:00 2001 From: eunhhu Date: Thu, 24 Sep 2026 17:14:54 +0100 Subject: Establish native micro runtime checkpoint --- prototypes/connections/check_tui.py | 352 ++++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 prototypes/connections/check_tui.py (limited to 'prototypes/connections/check_tui.py') diff --git a/prototypes/connections/check_tui.py b/prototypes/connections/check_tui.py new file mode 100644 index 0000000..5328d02 --- /dev/null +++ b/prototypes/connections/check_tui.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""Exercise the real terminal editor through a PTY and the same document CLI.""" +import argparse +import fcntl +import hashlib +import json +import locale +import os +from pathlib import Path +import pty +import select +import signal +import struct +import subprocess +import tempfile +import termios +import time + +HERE = Path(__file__).resolve().parent + + +def command(*args): + return subprocess.run([str(arg) for arg in args], capture_output=True, + timeout=20, check=True) + + +def utf8_locale(): + original = locale.setlocale(locale.LC_CTYPE) + candidates = [os.environ.get('LC_ALL'), os.environ.get('LC_CTYPE'), + os.environ.get('LANG'), 'C.UTF-8', 'en_US.UTF-8'] + try: + for candidate in candidates: + if not candidate: + continue + try: + locale.setlocale(locale.LC_CTYPE, candidate) + except locale.Error: + continue + if locale.nl_langinfo(locale.CODESET).upper().replace('-', '') == 'UTF8': + return candidate + finally: + locale.setlocale(locale.LC_CTYPE, original) + raise AssertionError('no UTF-8 locale available for terminal test') + + +def memory_kib(pid, field, portable=False): + if portable: + return None + for line in Path(f'/proc/{pid}/status').read_text().splitlines(): + if line.startswith(field): + return int(line.split()[1]) + raise AssertionError(field) + + +class Terminal: + def __init__(self, binary, program, store, key): + self.master, self.slave = pty.openpty() + fcntl.ioctl(self.slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) + self.original = termios.tcgetattr(self.slave) + language = utf8_locale() + self.process = subprocess.Popen([str(binary), + *(['--program', str(program)] if program else []), + '--store', str(store), key], + stdin=self.slave, stdout=self.slave, stderr=self.slave, + env={**os.environ, 'LANG': language, + 'LC_ALL': language}) + self.wait_for(f'Pure | {key}'.encode()) + + def wait_for(self, marker, timeout=8): + seen = b'' + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + ready, _, _ = select.select([self.master], [], [], 0.1) + if ready: + part = os.read(self.master, 65536) + seen = (seen + part)[-65536:] + if marker in seen: + return seen + if self.process.poll() is not None: + break + raise AssertionError((marker, self.process.poll(), seen[-2000:])) + + def type(self, data): + os.write(self.master, data) + + def resize(self, rows, columns): + fcntl.ioctl(self.slave, termios.TIOCSWINSZ, + struct.pack('HHHH', rows, columns, 0, 0)) + os.kill(self.process.pid, signal.SIGWINCH) + + def close(self, discard=False): + if self.process.poll() is None: + self.type(b'\x11') + if discard: + self.wait_for(b'Unsaved draft') + self.type(b'\x11') + self.process.wait(timeout=8) + assert self.process.returncode == 0, self.process.returncode + restored = termios.tcgetattr(self.slave) + assert restored == self.original, (restored, self.original) + os.close(self.master) + os.close(self.slave) + + def signal_close(self): + os.kill(self.process.pid, signal.SIGTERM) + self.process.wait(timeout=8) + assert self.process.returncode == 0, self.process.returncode + assert termios.tcgetattr(self.slave) == self.original + os.close(self.master) + os.close(self.slave) + + +def shown(editor, store, key): + result = command(*editor, '--store', store, 'show', key) + return result.stdout, json.loads(result.stderr)['revision'] + + +def wait_stored(editor, store, key, expected, revision): + deadline = time.monotonic() + 8 + while time.monotonic() < deadline: + try: + body, found_revision = shown(editor, store, key) + if body == expected and found_revision == str(revision): + return + except subprocess.CalledProcessError: + pass + time.sleep(0.05) + raise AssertionError((key, expected[:100], shown(editor, store, key))) + + +def check(build, rss_limit_kib, source, program_path, portable=False): + binary = (build / ('pure-edit-tui' if source else 'pure-edit-tui-fixed')).resolve() + program = (program_path or HERE / 'examples/editor.pure').resolve() if source else None + editor = [str((build / ('pure-edit' if source else 'pure-edit-fixed')).resolve())] + if source: + editor += ['--program', str(program)] + with tempfile.TemporaryDirectory(prefix='pure-tui-') as name: + root = Path(name) + store = root / 'store' + if source: + invalid = root / 'invalid.pure' + invalid.write_text('not a pure program\n') + for candidate, marker in ((invalid, b'Program:'), + (HERE / 'examples/storage.pure', b'Program needs')): + denied = subprocess.run([str(binary), '--program', str(candidate), + '--store', str(store), 'note'], + capture_output=True, timeout=10) + assert denied.returncode == 2 and marker in denied.stderr + assert not store.exists() + terminal = Terminal(binary, program, store, 'note') + try: + terminal.type(b'hello Pure\x1b[D\x1b[D!\x13') + wait_stored(editor, store, 'note', b'hello Pu!re', 1) + if source: + assert command(*editor, '--store', store, 'show', 'note').stdout == b'hello Pu!re' + terminal.type(b'\x0fsecond\r') + terminal.wait_for(b'Pure | second') + terminal.type('안녕'.encode() + b'\x1b[D' + '하'.encode() + b'\x13') + terminal.wait_for('안하녕'.encode()) + wait_stored(editor, store, 'second', '안하녕'.encode(), 1) + terminal.resize(32, 100) + terminal.wait_for(b'Pure | second') + terminal.type(b'\x0fnote\r') + terminal.wait_for(b'Pure | note') + terminal.type(b'\x0c') + listing = terminal.wait_for(b'Pure | documents') + assert b'note' in listing and b'second' in listing, listing[-1000:] + terminal.type(b'\x1b[B\r') + terminal.wait_for(b'Pure | second') + terminal.type(b'\x0c\x1b[A\r') + terminal.wait_for(b'Pure | note') + terminal.type(b'\x0c') + terminal.wait_for(b'Pure | documents') + (store / 'obj-second').unlink() + terminal.type(b'\x1b[B\r') + terminal.wait_for(b'Document disappeared; refresh the list') + terminal.type(b'\x1b') + terminal.wait_for(b'Pure | note') + terminal.type(b'!') + terminal.type(b'\x0c') + terminal.wait_for(b'Save or reload the draft before browsing') + external = root / 'external' + external.write_bytes(b' remote') + command(*editor, '--store', store, 'edit', 'note', '1', '11', '0', external) + terminal.type(b'\x13') + terminal.wait_for(b'Version conflict') + assert shown(editor, store, 'note') == (b'hello Pu!re remote', '2') + small_peak = memory_kib(terminal.process.pid, 'VmHWM:', portable) + finally: + terminal.close(discard=True) + + multiline = b'first\n\tsecond\nthird' + insert = root / 'multiline-insert' + insert.write_bytes(multiline) + command(*editor, '--store', store, 'edit', 'multiline', '0', '0', '0', insert) + terminal = Terminal(binary, program, store, 'multiline') + try: + terminal.resize(25, 80) + terminal.wait_for(b'third') + terminal.type(b'!\x13') + wait_stored(editor, store, 'multiline', multiline + b'!', 2) + finally: + terminal.close() + terminal = Terminal(binary, program, store, 'multiline') + terminal.resize(25, 80) + terminal.wait_for(b'third!') + terminal.close() + + rows = [f'line-{index:02}'.encode() for index in range(60)] + insert.write_bytes(b'\n'.join(rows)) + command(*editor, '--store', store, 'edit', 'rows', '0', '0', '0', insert) + terminal = Terminal(binary, program, store, 'rows') + try: + terminal.type(b'\x1b[A' * 3 + b'X\x13') + rows[56] += b'X' + wait_stored(editor, store, 'rows', b'\n'.join(rows), 2) + terminal.wait_for(b'line-56X') + finally: + terminal.close() + + large = b'Q' * 1_048_576 + insert = root / 'large-insert' + insert.write_bytes(large) + command(*editor, '--store', store, 'edit', 'large', '0', '0', '0', insert) + terminal = Terminal(binary, program, store, 'large') + try: + terminal.type(b'Z') + terminal.wait_for(b'Z') + terminal.type(b'\x13') + wait_stored(editor, store, 'large', large + b'Z', 2) + large_peak = memory_kib(terminal.process.pid, 'VmHWM:', portable) + finally: + terminal.close() + if rss_limit_kib: + assert large_peak <= rss_limit_kib, (large_peak, rss_limit_kib) + + unicode_body = '안녕'.encode() * 174_762 + insert.write_bytes(unicode_body) + command(*editor, '--store', store, 'edit', 'unicode', '0', '0', '0', insert) + terminal = Terminal(binary, program, store, 'unicode') + try: + terminal.type(b'K') + terminal.wait_for(b'K') + terminal.type(b'\x1b[HX') + terminal.wait_for(b'X' + '안녕'.encode()) + terminal.type(b'\x13') + wait_stored(editor, store, 'unicode', b'X' + unicode_body + b'K', 2) + finally: + terminal.close() + + listed = root / 'listed-store' + insert.write_bytes(b'X') + for index in range(1024): + command(*editor, '--store', listed, 'edit', f'doc{index:04}', '0', '0', '0', insert) + terminal = Terminal(binary, program, listed, 'doc0000') + try: + terminal.type(b'\x0c') + terminal.wait_for(b'Pure | documents | 1024 keys') + terminal.resize(8, 12) + terminal.wait_for(b'\033[8;1H') + terminal.resize(24, 80) + terminal.wait_for(b'Pure | documents | 1024 keys') + terminal.type(b'\x1b[F\r') + terminal.wait_for(b'Pure | doc1023') + assert shown(editor, listed, 'doc1023') == (b'X', '1') + terminal.type(b'\x0c') + terminal.wait_for(b'Pure | documents | 1024 keys') + terminal.type(b'doc0919') + terminal.wait_for(b'Find: doc0919_') + terminal.type(b'\r') + terminal.wait_for(b'Pure | doc0919') + assert shown(editor, listed, 'doc0919') == (b'X', '1') + terminal.type(b'\x0czz') + terminal.wait_for(b'No matching key') + terminal.type(b'\r') + terminal.wait_for(b'No matching key') + terminal.type(b'\x7f\x7fdoc1023\r') + terminal.wait_for(b'Pure | doc1023') + list_peak = memory_kib(terminal.process.pid, 'VmHWM:', portable) + finally: + terminal.close() + if rss_limit_kib: + assert list_peak <= rss_limit_kib, (list_peak, rss_limit_kib) + + if source: + alternate = root / 'alternate.pure' + definition = program.read_text() + fields = ' start: u32le;\n remove: u32le;' + assert fields in definition + alternate.write_text(definition.replace(fields, + ' remove: u32le;\n start: u32le;')) + alternate_store = root / 'alternate-store' + terminal = Terminal(binary, alternate, alternate_store, 'different_layout') + try: + terminal.type(b'abcdef\x13') + wait_stored(editor, alternate_store, 'different_layout', b'abcdef', 1) + terminal.type(b'\x1b[D\x1b[D\x1b[D!\x13') + wait_stored(editor, alternate_store, 'different_layout', b'abc!def', 2) + finally: + terminal.close() + + terminal = Terminal(binary, program, store, 'note') + terminal.signal_close() + return {'profile': 'native-source-document-terminal-editor' if source else + 'native-fixed-document-terminal-editor', + 'checks': (['source parse and incompatible definitions fail before store creation', + 'source CLI and TUI read the same document', + 'changed source field layout drives TUI input encoding'] if source else []) + + ['cursor insertion and save through terminal', 'open another key', + 'bounded document browser selects keys and protects unsaved draft', + 'UTF-8 codepoint movement, visible redraw and edit', 'resize redraw', + 'external revision conflict preserves draft', + 'stored multiline and tabbed document survives edit and reopen', + 'cached viewport preserves vertical movement across many rows', + 'one MiB document viewport, edit and fresh CLI restore', + 'one MiB UTF-8 viewport and edit before cached anchor', + '1,024 real documents listed, searched, resized and opened' + + (' within RSS gate' if not portable else ''), + 'terminal mode restored after key and signal exit'], + 'small_peak_rss_kib': small_peak, + 'one_mib_peak_rss_kib': large_peak, + 'one_thousand_key_list_peak_rss_kib': list_peak, + 'one_mib_peak_rss_limit_kib': rss_limit_kib, + 'portable': portable, + 'terminal_locale': utf8_locale(), + 'binary_sha256': hashlib.sha256(binary.read_bytes()).hexdigest()} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--build', type=Path, default=HERE / 'build') + parser.add_argument('--rss-limit-kib', type=int, default=4096, + help='0 skips the release RSS gate for sanitizer builds') + parser.add_argument('--source', action='store_true', help='check the source-loaded TUI') + parser.add_argument('--portable', action='store_true', + help='run all PTY behavior without Linux /proc RSS measurement') + parser.add_argument('--program', type=Path, help='definition for --source (including relocated bundles)') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + result = check(args.build, 0 if args.portable else args.rss_limit_kib, + args.source, args.program, args.portable) + body = json.dumps(result, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(body) + print(body, end='') + + +if __name__ == '__main__': + main() -- cgit v1.2.3