diff options
| author | eunhhu <[email protected]> | 2026-09-24 17:14:54 +0100 |
|---|---|---|
| committer | eunhhu <[email protected]> | 2026-09-24 17:14:54 +0100 |
| commit | b1ce1927022d374198441afafeb27199fc8a052a (patch) | |
| tree | 6021f175f4c67ed03b44240eaa2f90d846f12f6f /prototypes/connections/results/2026-09-23-native-connections | |
| parent | d34f9426d5317a1948f12f229439946728bc342a (diff) | |
| download | pure-b1ce1927022d374198441afafeb27199fc8a052a.tar.gz pure-b1ce1927022d374198441afafeb27199fc8a052a.zip | |
Establish native micro runtime checkpoint
Diffstat (limited to 'prototypes/connections/results/2026-09-23-native-connections')
20 files changed, 2535 insertions, 0 deletions
diff --git a/prototypes/connections/results/2026-09-23-native-connections/README.md b/prototypes/connections/results/2026-09-23-native-connections/README.md new file mode 100644 index 0000000..0e4f385 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/README.md @@ -0,0 +1,26 @@ +# 018 검증 자료 + +> 이 SQLite 구현은 [019](../2026-09-23-byte-storage/README.md)의 SQL 없는 경로로 교체했다. +> 이 디렉터리의 원자료와 소스 사본은 당시 결과를 보존한다. 현재 작업 트리에서 실행하면 +> 새 구현을 검사하므로, 당시 구현의 재현에는 `sources/`의 파일들을 별도 작업 트리에 사용한다. + +이 디렉터리는 실제 루프백 TCP·SQLite·기존 Workbench CLI를 연결한 기능·수명 검사 자료다. +속도·처리량·전체 RSS 비교는 수행하지 않았다. + +- [native.json](native.json): 일반 빌드의 15개 통합 시나리오, 종료 시 자원 수, 환경과 소스·실행 파일 해시. +- [sanitized.json](sanitized.json): ASan·UBSan 빌드에서 같은 시나리오. 누수 검사를 비활성화하지 않았다. +- [verification.json](verification.json): 빌드·실행 명령, 코어·CLI 결과와 파일 일치 확인. + +서버가 만든 임시 DB에 실제 작성물 배치를 저장하고 새 CLI에서 복원했다. 객체 버전의 +충돌과 64비트 정밀도, 저장 오류, DB 잠금, 읽는 중 다른 커밋, 부분 입력·출력, 연결 종료, +한도와 기한을 검사한다. 큰 본문은 부분 전송·한도 거절을 발생시키기 위한 정확성 fixture다. +작업량을 크게 만들어 성능을 주장하는 벤치마크가 아니다. + +`retained_payload_bytes`는 수신 할당과 빌린 DB 결과의 길이 합이다. 실제 SQLite·OS·전체 +프로세스 메모리는 포함하지 않는다. `ingress_prefix_copy_bytes`는 서버가 직접 복사한 +수신 접두부만 센다. 전송 배치에 따라 값이 바뀔 수 있어 두 빌드의 시간·복사 효율 비교에 쓰지 않는다. + +재현은 [구현 README](../../README.md)의 `make check` 명령을 따른다. `EVIDENCE=/새/경로.json`을 +추가하면 새 증거 파일을 저장한다. 이미 있는 파일을 덮어쓰지 않는다. 실제 소켓·자식 프로세스· +LeakSanitizer가 제한된 환경에서는 권한이 필요하다. 이 실행에서는 샌드박스가 해당 기능을 +차단해 승인된 샌드박스 밖의 로컬 검사로 확인했다. 외부 네트워크 서비스는 사용하지 않았다. diff --git a/prototypes/connections/results/2026-09-23-native-connections/native.json b/prototypes/connections/results/2026-09-23-native-connections/native.json new file mode 100644 index 0000000..5f7e53f --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/native.json @@ -0,0 +1,98 @@ +{ + "kind": "native-path-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "busy and injected DB failure do not acknowledge or change data", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines" + ], + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 38, + "commits": 11, + "gets": 13, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 31, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115411, + "server_structure_bytes": 39112 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 39112 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 39112 + } + ], + "sqlite_runtime": "3.46.1", + "binary_sha256": "ff1c2d09d940d9f9a9170e6fea894aee9926e98f04567d389a0c00d5ddf4c7f7", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "b0b50b0d85900b26257aac68177951e8319fd5482a1fcff4c91722f7ba30f74c", + "prototypes/connections/store.h": "07d347ae872a3d618dda1697b01bb32abee44b3451472a1b2225963085c11cf2", + "prototypes/connections/server.c": "3ff9d9497ad2a8ece657b50af9f8e6e564a974e6bbe3542f0ebe32279883d219", + "prototypes/connections/check.py": "be75db13ab2f11d3dd0e064aaa271bf28145aef11423e8ed536c755dcf96eaac", + "prototypes/connections/test-flow.c": "17472b90ebd3067b43a0cd06fb571111206b2025a2bd552b1602c3189f17ce0b", + "prototypes/connections/Makefile": "6c05917782adf733d31d9bfd42ee8f41b05fe94d965c362577189d6849b61e3e", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sanitized.json b/prototypes/connections/results/2026-09-23-native-connections/sanitized.json new file mode 100644 index 0000000..c391070 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sanitized.json @@ -0,0 +1,98 @@ +{ + "kind": "native-path-correctness-not-performance", + "checks": [ + "fragmented request and binary bytes round trip", + "conditional create and stale revision preserve committed bytes", + "busy and injected DB failure do not acknowledge or change data", + "incomplete request never commits and releases its allocation", + "borrowed read remains the same snapshot during another commit", + "receiver reset aborts an outstanding borrowed transfer", + "unsupported framing and oversized payload are rejected before effects", + "existing workbench manipulation restores in a fresh runtime and reports conflict", + "invalid or non-idle restore preserves local state and does not adopt remote revision", + "revision above JavaScript safe integer round trips without numeric coercion", + "unread acknowledgment does not undo commit or permit a stale retry", + "empty body has a valid lifetime and stored revision", + "committed authoring state survives process restart", + "slow consumer retains its lease; budget rejects another; deadline releases it", + "connection slots stay bounded and recover after header deadlines" + ], + "server_runs": [ + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048583, + "payload_budget": 4194304, + "peak_connections": 2, + "accepted": 38, + "commits": 11, + "gets": 13, + "conflicts": 4, + "budget_rejections": 0, + "expired": 0, + "releases": 31, + "ingress_prefix_copy_bytes": 11674, + "borrowed_output_bytes": 2115411, + "server_structure_bytes": 39112 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 2553, + "payload_budget": 4194304, + "peak_connections": 1, + "accepted": 2, + "commits": 0, + "gets": 2, + "conflicts": 0, + "budget_rejections": 0, + "expired": 0, + "releases": 2, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 2556, + "server_structure_bytes": 39112 + }, + { + "active": 0, + "retained_payload_bytes": 0, + "peak_retained_payload_bytes": 1048576, + "payload_budget": 1048576, + "peak_connections": 8, + "accepted": 12, + "commits": 0, + "gets": 3, + "conflicts": 0, + "budget_rejections": 1, + "expired": 9, + "releases": 3, + "ingress_prefix_copy_bytes": 0, + "borrowed_output_bytes": 1048582, + "server_structure_bytes": 39112 + } + ], + "sqlite_runtime": "3.46.1", + "binary_sha256": "2e3bb0802082637989041063c2bc34ad6c9a072abc257d7adefdc65cba315298", + "environment": { + "platform": "Linux-6.18.39+rpt-rpi-2712-aarch64-with-glibc2.41", + "machine": "aarch64", + "python": "3.14.7", + "node": "v24.20.0" + }, + "source_sha256": { + "prototypes/connections/flow.c": "3957de06e33985a8310954af07b33c2a1d02a4afdf9bd112d06c99c0b5df7749", + "prototypes/connections/flow.h": "289ff43d191270066e238235023f096946f3f561ec5825d2f6c26006db5fc421", + "prototypes/connections/store.c": "b0b50b0d85900b26257aac68177951e8319fd5482a1fcff4c91722f7ba30f74c", + "prototypes/connections/store.h": "07d347ae872a3d618dda1697b01bb32abee44b3451472a1b2225963085c11cf2", + "prototypes/connections/server.c": "3ff9d9497ad2a8ece657b50af9f8e6e564a974e6bbe3542f0ebe32279883d219", + "prototypes/connections/check.py": "be75db13ab2f11d3dd0e064aaa271bf28145aef11423e8ed536c755dcf96eaac", + "prototypes/connections/test-flow.c": "17472b90ebd3067b43a0cd06fb571111206b2025a2bd552b1602c3189f17ce0b", + "prototypes/connections/Makefile": "6c05917782adf733d31d9bfd42ee8f41b05fe94d965c362577189d6849b61e3e", + "prototypes/workbench/remote.mjs": "6f5aa2bc5a3c88749b2b688c5631eed9e4a5ab41d72eca168bfb67941a8cff2f", + "prototypes/workbench/cli.mjs": "55a5ead56fc8ad4c3f2391505fc3dbfa477ccff5b68fc208f700da24d5e194da", + "prototypes/workbench/web/core.wasm": "9f760e4a2ce6ea11f2be07693fceca74088884885cc545a70ceef5003d0b2479", + "prototypes/workbench/web/runtime.mjs": "9cf12270bb39b3d6ce0384c56df8bdfabfd9459d3375cc7819290a5fe3e5db54", + "prototypes/workbench/web/compiler.mjs": "349139612d1f7da5f75ef21c65aa697963b760cb35321150fc36f67f3b40adf1", + "prototypes/workbench/web/project.mjs": "8af4cd1a60bbb217c896be20c7c6b8d33989f2166d06e736bf1479d8ecdc8b35", + "prototypes/workbench/examples/direct.pure": "57177e5197d75a6185cd3ac5df2be920ddba9a9b0fcc2dddab3560b815249f0a" + } +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/Makefile b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/Makefile new file mode 100644 index 0000000..c520aa5 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/Makefile @@ -0,0 +1,22 @@ +CC ?= cc +CPPFLAGS += $(shell pkg-config --cflags sqlite3) +CFLAGS ?= -O2 -g +CFLAGS += -std=c17 -Wall -Wextra -Wpedantic -Werror +LDLIBS += $(shell pkg-config --libs sqlite3) +BUILD ?= build + +.PHONY: all check +all: $(BUILD)/pure-connections + +$(BUILD): + mkdir -p $@ + +$(BUILD)/pure-connections: server.c store.c flow.c store.h flow.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) server.c store.c flow.c $(LDFLAGS) $(LDLIBS) -o $@ + +$(BUILD)/test-flow: test-flow.c store.c flow.c store.h flow.h | $(BUILD) + $(CC) $(CPPFLAGS) $(CFLAGS) test-flow.c store.c flow.c $(LDFLAGS) $(LDLIBS) -o $@ + +check: $(BUILD)/test-flow $(BUILD)/pure-connections + $(BUILD)/test-flow + python3 check.py --server $(BUILD)/pure-connections $(if $(EVIDENCE),--output "$(EVIDENCE)") diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/check.py b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/check.py new file mode 100644 index 0000000..c19fcaa --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/check.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Exercise the real local TCP/SQLite path and the existing workbench CLI.""" +import argparse +from contextlib import contextmanager +import hashlib +import http.client +import json +import platform +from pathlib import Path +import select +import socket +import sqlite3 +import struct +import subprocess +import tempfile +import time + +ROOT = Path(__file__).resolve().parent +WORKBENCH = ROOT.parent / 'workbench' +OBJECT_LIMIT = (2 * 6 + 32) * 32768 + 8192 + + +class Server: + def __init__(self, binary, database, budget=4 * 1024 * 1024, timeout=5000): + self.process = subprocess.Popen([str(binary), '--db', str(database), '--port', '0', + '--budget', str(budget), '--timeout-ms', str(timeout), '--send-buffer', '4096'], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if not select.select([self.process.stdout], [], [], 15)[0]: + self.process.kill() + raise AssertionError('server readiness timed out') + line = self.process.stdout.readline() + if not line: + raise AssertionError(self.process.communicate(timeout=5)) + self.ready = json.loads(line) + self.port = self.ready['port'] + self.stats = None + + def close(self): + if self.process.poll() is None: + self.process.terminate() + try: + stdout, stderr = self.process.communicate(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.communicate() + raise + assert self.process.returncode == 0, (stdout, stderr) + self.stats = json.loads(stderr) + assert self.stats['active'] == self.stats['retained_payload_bytes'] == 0 + assert self.stats['peak_retained_payload_bytes'] <= self.stats['payload_budget'] + assert self.stats['peak_connections'] <= 8 + + +@contextmanager +def serving(*args, **kwargs): + server = Server(*args, **kwargs) + try: + yield server + finally: + server.close() + + +def rpc(server, method, key, body=b'', expected=None): + headers = {'Content-Length': str(len(body))} + if expected is not None: + headers['If-None-Match' if expected == 0 else 'If-Match'] = '*' if expected == 0 else f'"{expected}"' + connection = http.client.HTTPConnection('127.0.0.1', server.port, timeout=8) + try: + connection.request(method, f'/objects/{key}', body, headers) + response = connection.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + connection.close() + + +def wire(server, request, chunks=None): + with socket.create_connection(('127.0.0.1', server.port), timeout=8) as sock: + if chunks: + for chunk in chunks: + sock.sendall(chunk) + else: + sock.sendall(request) + response = http.client.HTTPResponse(sock) + response.begin() + return response.status, dict(response.getheaders()), response.read() + + +def header(method, key, size=0, condition=b''): + return (f'{method} /objects/{key} HTTP/1.1\r\nHost: localhost\r\n' + f'Content-Length: {size}\r\n'.encode() + condition + b'\r\n') + + +def cli(server, commands, source=None, expected_code=0): + result = subprocess.run(['node', str(WORKBENCH / 'cli.mjs'), *([str(source)] if source else [])], + input=commands.replace('$URL', f'http://127.0.0.1:{server.port}'), text=True, + capture_output=True, timeout=30) + assert result.returncode == expected_code, (result.stdout, result.stderr) + return [json.loads(line) for line in result.stdout.splitlines()], result.stderr + + +def check(binary, directory): + database = directory / 'objects.db' + checks, records = [], [] + with serving(binary, database) as server: + assert rpc(server, 'GET', 'absent')[0] == 404 + payload = b'\x00\xffbinary\r\n\r\n' + bytes(range(256)) * 41 + request = header('PUT', 'binary', len(payload), b'If-None-Match: *\r\n') + payload + status, headers, _ = wire(server, request, [request[:7], request[7:51], request[51:57], request[57:]]) + assert status == 200 and headers['ETag'] == '"1"' + assert rpc(server, 'GET', 'binary')[2] == payload + checks.append('fragmented request and binary bytes round trip') + assert rpc(server, 'PUT', 'binary', b'new', 0)[0] == 412 + assert rpc(server, 'PUT', 'binary', b'new', 1)[0] == 200 + assert rpc(server, 'PUT', 'binary', b'stale', 1)[0] == 412 + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('conditional create and stale revision preserve committed bytes') + + lock = sqlite3.connect(database, timeout=0) + lock.execute('BEGIN IMMEDIATE') + assert rpc(server, 'PUT', 'binary', b'blocked', 2)[0] == 503 + lock.rollback() + lock.execute("CREATE TRIGGER reject_write BEFORE UPDATE ON pure_objects BEGIN SELECT RAISE(ABORT,'test failure'); END") + lock.commit() + assert rpc(server, 'PUT', 'binary', b'failure', 2)[0] == 500 + assert rpc(server, 'GET', 'binary')[2] == b'new' + lock.execute('DROP TRIGGER reject_write') + lock.commit() + lock.close() + checks.append('busy and injected DB failure do not acknowledge or change data') + + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('PUT', 'incomplete', 100, b'If-None-Match: *\r\n') + b'partial') + sock.shutdown(socket.SHUT_WR) + assert sock.recv(1) == b'' + sock.close() + assert rpc(server, 'GET', 'incomplete')[0] == 404 + checks.append('incomplete request never commits and releases its allocation') + + # Force genuine partial socket sends. A write on another connection must + # not alter the SQLite row whose bytes are still being transmitted. + large = bytes(range(256)) * 4096 + assert rpc(server, 'PUT', 'large', large, 0)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 and response.getheader('ETag') == '"1"' + assert response.read(17) == large[:17] + assert rpc(server, 'PUT', 'large', b'changed', 1)[0] == 200 + assert response.read() == large[17:] + sock.close() + assert rpc(server, 'GET', 'large')[2] == b'changed' + checks.append('borrowed read remains the same snapshot during another commit') + + assert rpc(server, 'PUT', 'large', large, 2)[0] == 200 + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('GET', 'large')) + assert sock.recv(1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack('ii', 1, 0)) + sock.close() + assert rpc(server, 'GET', 'binary')[2] == b'new' + checks.append('receiver reset aborts an outstanding borrowed transfer') + + assert wire(server, header('PUT', 'too_large', OBJECT_LIMIT + 1, b'If-None-Match: *\r\n'))[0] == 413 + malformed = [ + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nContent-Length: 1\r\nContent-Length: 1\r\nIf-None-Match: *\r\n\r\nx', + b'PUT /objects/bad HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n', + b'GET /objects/bad HTTP/1.1\r\nHost: x\x00hidden\r\n\r\n', + ] + for request in malformed: + assert wire(server, request)[0] == 400 + assert rpc(server, 'GET', 'bad')[0] == 404 + checks.append('unsupported framing and oversized payload are rejected before effects') + + # Existing authoring path: define, manipulate, save remotely, reconstruct + # in a fresh runtime. No fabricated project fixture or mock database. + source = WORKBENCH / 'examples' / 'direct.pure' + saved = directory / 'restored.json' + lines, _ = cli(server, 'point drag down 80 180\npoint drag move 210 210\npoint drag up 210 210\n' + 'connect $URL authored\npush 0\nquit\n', source) + snapshots = [line for line in lines if 'states' in line] + assert snapshots + expected_states = snapshots[-1]['states'] + assert expected_states['x'] == 210 and expected_states['y'] == 190 and expected_states['dragging'] == 0 + lines, _ = cli(server, f'connect $URL authored\npull\nsave-project {saved}\nquit\n') + assert [line for line in lines if 'states' in line][-1]['states'] == expected_states + document = json.loads(saved.read_text()) + assert document['version'] == 2 and document['source'] == source.read_text() + lines, error = cli(server, 'connect $URL authored\npush 0\nquit\n', source, 1) + assert '412' in error + checks.append('existing workbench manipulation restores in a fresh runtime and reports conflict') + + assert rpc(server, 'PUT', 'invalid_project', b'{"format":"pure-workbench","version":2,"source":"state x=1","states":[]}', 0)[0] == 200 + lines, error = cli(server, 'show\nconnect $URL invalid_project\npull\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and error + lines, error = cli(server, 'point drag down 90 170\nconnect $URL authored\npull\npush\nshow\nquit\n', source, 1) + states = [line['states'] for line in lines if 'states' in line] + assert states[0] == states[-1] and states[-1]['dragging'] == 1 + assert len(error.strip().splitlines()) == 2 + checks.append('invalid or non-idle restore preserves local state and does not adopt remote revision') + + assert rpc(server, 'PUT', 'wide_revision', rpc_bytes(saved), 0)[0] == 200 + with sqlite3.connect(database) as database_connection: + database_connection.execute('UPDATE pure_objects SET revision=? WHERE key=?', (9007199254740993, 'wide_revision')) + lines, _ = cli(server, 'connect $URL wide_revision\npull\npush\nquit\n') + revisions = [line['revision'] for line in lines if 'remote' in line and 'revision' in line] + assert revisions == ['9007199254740993', '9007199254740994'] + checks.append('revision above JavaScript safe integer round trips without numeric coercion') + + sock = socket.create_connection(('127.0.0.1', server.port), timeout=8) + sock.sendall(header('PUT', 'unread_ack', 4, b'If-None-Match: *\r\n') + b'done') + try: + deadline = time.monotonic() + 3 + while True: + with sqlite3.connect(database) as database_connection: + committed = database_connection.execute('SELECT body FROM pure_objects WHERE key=?', ('unread_ack',)).fetchone() + if committed: + assert committed[0] == b'done' + break + assert time.monotonic() < deadline + time.sleep(0.01) + finally: + sock.close() # Client never consumes the successful response. + assert rpc(server, 'PUT', 'unread_ack', b'again', 0)[0] == 412 + assert rpc(server, 'GET', 'unread_ack')[2] == b'done' + checks.append('unread acknowledgment does not undo commit or permit a stale retry') + + # A disconnect after commit can hide the ACK; retries remain conditional. + assert rpc(server, 'PUT', 'empty', b'', 0)[0] == 200 + assert rpc(server, 'GET', 'empty')[2] == b'' + checks.append('empty body has a valid lifetime and stored revision') + records.append(server.stats) + + with serving(binary, database) as restarted: + assert rpc(restarted, 'GET', 'binary')[2] == b'new' + assert rpc(restarted, 'GET', 'authored')[2] == rpc_bytes(saved) + checks.append('committed authoring state survives process restart') + records.append(restarted.stats) + + # Fixed small payload budget is an admission test, not a throughput workload. + with serving(binary, database, budget=1024 * 1024, timeout=1200) as limited: + sock = socket.create_connection(('127.0.0.1', limited.port), timeout=8) + sock.sendall(header('GET', 'large')) + response = http.client.HTTPResponse(sock) + response.begin() + assert response.status == 200 + assert response.read(1) == large[:1] + status, headers, _ = rpc(limited, 'GET', 'large') + assert status == 503 and headers['X-Pure-Error'] == 'payload_budget' + # Leave the first receiver stalled until the absolute connection deadline. + time.sleep(1.4) + assert rpc(limited, 'GET', 'binary')[2] == b'new' + response.close() + sock.close() + checks.append('slow consumer retains its lease; budget rejects another; deadline releases it') + + idle = [socket.create_connection(('127.0.0.1', limited.port), timeout=8) for _ in range(8)] + try: + for s in idle: + s.sendall(b'G') + for s in idle: + assert s.recv(1) == b'' + finally: + for s in idle: + s.close() + assert rpc(limited, 'GET', 'binary')[0] == 200 + checks.append('connection slots stay bounded and recover after header deadlines') + assert limited.stats['budget_rejections'] >= 1 and limited.stats['expired'] >= 9 + records.append(limited.stats) + return {'kind': 'native-path-correctness-not-performance', 'checks': checks, + 'server_runs': records, 'sqlite_runtime': server.ready['sqlite']} + + +def rpc_bytes(path): + # CLI export is pretty JSON; wire export is compact JSON with the same model. + return json.dumps(json.loads(path.read_text()), ensure_ascii=False, separators=(',', ':')).encode() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--server', type=Path, default=ROOT / 'build/pure-connections') + parser.add_argument('--output', type=Path) + args = parser.parse_args() + if args.output and args.output.exists(): + parser.error('evidence file already exists') + binary = args.server.resolve() + with tempfile.TemporaryDirectory(prefix='pure-connections-check-') as temporary: + result = check(binary, Path(temporary)) + result['binary_sha256'] = hashlib.sha256(binary.read_bytes()).hexdigest() + result['environment'] = {'platform': platform.platform(), 'machine': platform.machine(), + 'python': platform.python_version(), 'node': subprocess.check_output(['node', '--version'], text=True).strip()} + result['source_sha256'] = {str(path.relative_to(ROOT.parent.parent)): hashlib.sha256(path.read_bytes()).hexdigest() + for path in [*(ROOT / name for name in ('flow.c', 'flow.h', 'store.c', 'store.h', 'server.c', 'check.py', 'test-flow.c', 'Makefile')), + *(WORKBENCH / name for name in ('remote.mjs', 'cli.mjs', 'web/core.wasm', 'web/runtime.mjs', + 'web/compiler.mjs', 'web/project.mjs', 'examples/direct.pure'))]} + output = json.dumps(result, ensure_ascii=False, indent=2) + '\n' + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open('x') as destination: + destination.write(output) + print(output, end='') + + +if __name__ == '__main__': + main() diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/flow.c b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/flow.c new file mode 100644 index 0000000..1070c70 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/flow.c @@ -0,0 +1,41 @@ +#include "flow.h" +#include <assert.h> + +void pure_transfer_abort(PureTransfer *t) { + if (!t->active) return; + PureRelease release = t->release; + void *owner = t->owner; + *t = (PureTransfer){0}; + if (release) release(owner); +} + +static void advance(PureTransfer *t) { + while (t->active && t->part < 2 && t->offset == t->spans[t->part].size) { + t->part++; + t->offset = 0; + } + if (t->active && t->part == 2) pure_transfer_abort(t); +} + +void pure_transfer_init(PureTransfer *t, PureSpan header, PureSpan body, + void *owner, PureRelease release) { + assert(!t->active); + assert((!header.size || header.data) && (!body.size || body.data)); + *t = (PureTransfer){.spans = {header, body}, .owner = owner, + .release = release, .active = true}; + advance(t); +} + +PureSpan pure_transfer_next(const PureTransfer *t, size_t limit) { + if (!t->active) return (PureSpan){0}; + PureSpan span = t->spans[t->part]; + size_t remaining = span.size - t->offset; + return (PureSpan){span.data + t->offset, remaining < limit ? remaining : limit}; +} + +bool pure_transfer_consume(PureTransfer *t, size_t bytes) { + if (!t->active || bytes > t->spans[t->part].size - t->offset) return false; + t->offset += bytes; + advance(t); + return true; +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/flow.h b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/flow.h new file mode 100644 index 0000000..6e27c58 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/flow.h @@ -0,0 +1,25 @@ +#ifndef PURE_FLOW_H +#define PURE_FLOW_H +#include <stdbool.h> +#include <stddef.h> + +typedef struct { const unsigned char *data; size_t size; } PureSpan; +typedef void (*PureRelease)(void *owner); + +/* A borrowed body stays alive until every byte is consumed or the transfer is + * aborted. A successful send() consumes bytes; peer receipt is a separate fact. + * Single owner thread; callbacks must not reenter or reuse this transfer. */ +typedef struct { + PureSpan spans[2]; + size_t part, offset; + void *owner; + PureRelease release; + bool active; +} PureTransfer; + +void pure_transfer_init(PureTransfer *, PureSpan header, PureSpan body, + void *owner, PureRelease); +PureSpan pure_transfer_next(const PureTransfer *, size_t limit); +bool pure_transfer_consume(PureTransfer *, size_t bytes); +void pure_transfer_abort(PureTransfer *); +#endif diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/server.c b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/server.c new file mode 100644 index 0000000..8f7b1ed --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/server.c @@ -0,0 +1,346 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include <arpa/inet.h> +#include <assert.h> +#include <errno.h> +#include <fcntl.h> +#include <inttypes.h> +#include <poll.h> +#include <signal.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <strings.h> +#include <sys/socket.h> +#include <time.h> +#include <unistd.h> + +enum { SLOTS = 8, HEADER_LIMIT = 4096, CHUNK = 65536 }; +typedef enum { READING_HEADER, READING_BODY, WRITING } Phase; +typedef struct Server Server; +typedef struct { + int fd; + Phase phase; + uint64_t deadline; + char request[HEADER_LIMIT + 1], response[512], key[65]; + size_t header_size, body_size, received, charged; + unsigned char *body; + bool put; + int64_t expected; + PureStoredView stored; + PureTransfer transfer; + Server *server; +} Connection; +struct Server { + const char *path; + size_t budget, retained, peak_retained, active, peak_active; + uint64_t accepted, commits, gets, conflicts, budget_rejections, expired; + uint64_t releases, prefix_copy_bytes, borrowed_output_bytes; + uint64_t timeout_ms; + int send_buffer; + Connection connections[SLOTS]; +}; +static volatile sig_atomic_t stopping; +static void stop_server(int sig) { (void)sig; stopping = 1; } + +static uint64_t now_ms(void) { + struct timespec t; + if (clock_gettime(CLOCK_MONOTONIC, &t)) abort(); + return (uint64_t)t.tv_sec * 1000 + (uint64_t)t.tv_nsec / 1000000; +} + +static bool number(const char *text, uint64_t maximum, uint64_t *value) { + if (!*text) return false; + uint64_t n = 0; + for (; *text; ++text) { + if (*text < '0' || *text > '9') return false; + unsigned digit = (unsigned)(*text - '0'); + if (digit > maximum || n > (maximum - digit) / 10) return false; + n = n * 10 + digit; + } + *value = n; + return true; +} + +static void release_payload(void *owner) { + Connection *c = owner; + bool owned = c->body || c->stored.db || c->charged; + pure_store_release(&c->stored); + free(c->body); + c->body = NULL; + assert(c->charged <= c->server->retained); + c->server->retained -= c->charged; + c->charged = 0; + if (owned) ++c->server->releases; +} + +static void close_connection(Connection *c) { + if (c->fd < 0) return; + if (c->transfer.active) pure_transfer_abort(&c->transfer); + else if (c->body || c->stored.db || c->charged) release_payload(c); + close(c->fd); + --c->server->active; + *c = (Connection){.fd = -1}; +} + +static bool charge(Connection *c, size_t size) { + Server *s = c->server; + if (size > s->budget - s->retained) { ++s->budget_rejections; return false; } + s->retained += size; + c->charged += size; + if (s->retained > s->peak_retained) s->peak_retained = s->retained; + return true; +} + +static void respond(Connection *c, int status, const char *reason, const char *error, + int64_t revision, PureSpan body) { + char tag[80] = ""; + if (revision > 0) snprintf(tag, sizeof(tag), "ETag: \"%" PRId64 "\"\r\n", revision); + int length = snprintf(c->response, sizeof(c->response), + "HTTP/1.1 %d %s\r\nContent-Length: %zu\r\nConnection: close\r\n" + "Content-Type: application/octet-stream\r\nCache-Control: no-store\r\n" + "X-Pure-Error: %s\r\n%s\r\n", status, reason, body.size, error, tag); + assert(length > 0 && (size_t)length < sizeof(c->response)); + c->phase = WRITING; + pure_transfer_init(&c->transfer, + (PureSpan){(const unsigned char *)c->response, (size_t)length}, body, c, release_payload); +} + +static void fail(Connection *c, int status, const char *reason, const char *error) { + /* No outstanding backend access on the owner thread at this point. */ + if (c->body || c->stored.db || c->charged) release_payload(c); + respond(c, status, reason, error, 0, (PureSpan){0}); +} + +static void run_request(Connection *c) { + PureResult result; + int64_t revision = 0; + if (c->put) { + result = pure_store_put(c->server->path, c->key, c->expected, + (PureSpan){c->body, c->body_size}, &revision); + /* Binding has been finalized, including the failure path. */ + release_payload(c); + if (result == PURE_OK) ++c->server->commits; + } else { + result = pure_store_get(c->server->path, c->key, + c->server->budget - c->server->retained, &c->stored); + if (result == PURE_OK) { + if (!charge(c, c->stored.body.size)) abort(); /* Same owner thread. */ + revision = c->stored.revision; + ++c->server->gets; + c->server->borrowed_output_bytes += c->stored.body.size; + } else if (result == PURE_BUDGET) ++c->server->budget_rejections; + } + switch (result) { + case PURE_OK: respond(c, 200, "OK", "none", revision, c->stored.body); break; + case PURE_NOT_FOUND: fail(c, 404, "Not Found", "not_found"); break; + case PURE_CONFLICT: + ++c->server->conflicts; + respond(c, 412, "Precondition Failed", "conflict", revision, (PureSpan){0}); break; + case PURE_BUSY: fail(c, 503, "Service Unavailable", "db_busy"); break; + case PURE_BUDGET: fail(c, 503, "Service Unavailable", "payload_budget"); break; + case PURE_LIMIT: fail(c, 413, "Content Too Large", "limit"); break; + default: fail(c, 500, "Internal Server Error", "storage"); break; + } +} + +/* Deliberately bounded HTTP/1.1 subset: one GET/PUT, Content-Length, close. + * No transfer coding, pipelining, trailers, Expect, URL decoding, or proxying. */ +static bool parse_header(Connection *c, size_t end) { + for (size_t i = 0; i < end; ++i) { + unsigned char b = (unsigned char)c->request[i]; + if ((b < 32 && b != '\r' && b != '\n' && b != '\t') || b == 127) return false; + } + char *line = c->request, *next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *path = strchr(line, ' '); + if (!path) return false; + *path++ = 0; + if (strcmp(line, "PUT") == 0) c->put = true; + else if (strcmp(line, "GET") != 0) return false; + char *version = strchr(path, ' '); + if (!version) return false; + *version++ = 0; + if (strcmp(version, "HTTP/1.1") || strncmp(path, "/objects/", 9)) return false; + const char *key = path + 9; + size_t key_size = strlen(key); + if (!key_size || key_size > 64) return false; + for (size_t i = 0; i < key_size; ++i) { + char b = key[i]; + if (!((b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || + (b >= '0' && b <= '9') || b == '-' || b == '_')) return false; + } + memcpy(c->key, key, key_size + 1); + bool length_seen = false, condition_seen = false, host_seen = false; + line = next + 2; + while (*line != '\r') { + next = strstr(line, "\r\n"); + if (!next) return false; + *next = 0; + char *value = strchr(line, ':'); + if (!value || value == line) return false; + *value++ = 0; + for (char *p = line; *p; ++p) + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || + (*p >= '0' && *p <= '9') || *p == '-')) return false; + while (*value == ' ' || *value == '\t') ++value; + char *tail = value + strlen(value); + while (tail > value && (tail[-1] == ' ' || tail[-1] == '\t')) *--tail = 0; + if (strchr(value, '\r') || strchr(value, '\n')) return false; + uint64_t n; + if (!strcasecmp(line, "Content-Length")) { + if (length_seen || !number(value, SIZE_MAX, &n)) return false; + length_seen = true; + c->body_size = (size_t)n; + } else if (!strcasecmp(line, "Host")) { + if (host_seen || !*value) return false; + host_seen = true; + } else if (!strcasecmp(line, "If-Match")) { + if (condition_seen || tail - value < 3 || *value != '"' || tail[-1] != '"') return false; + tail[-1] = 0; + if (!number(value + 1, INT64_MAX, &n) || !n) return false; + c->expected = (int64_t)n; + condition_seen = true; + } else if (!strcasecmp(line, "If-None-Match")) { + if (condition_seen || strcmp(value, "*")) return false; + c->expected = 0; + condition_seen = true; + } else if (!strcasecmp(line, "Transfer-Encoding") || !strcasecmp(line, "Expect")) return false; + line = next + 2; + } + return host_seen && (c->put ? length_seen && condition_seen : !condition_seen && c->body_size == 0); +} + +static void read_connection(Connection *c) { + if (c->phase == READING_HEADER) { + ssize_t n = recv(c->fd, c->request + c->header_size, HEADER_LIMIT - c->header_size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->header_size += (size_t)n; + c->request[c->header_size] = 0; + /* Scan by byte count, so embedded NUL cannot hide a terminator. */ + size_t end = 0; + for (size_t i = 3; i < c->header_size; ++i) + if (!memcmp(c->request + i - 3, "\r\n\r\n", 4)) { end = i + 1; break; } + if (!end) { + if (c->header_size == HEADER_LIMIT) fail(c, 431, "Request Header Fields Too Large", "headers"); + return; + } + size_t prefix = c->header_size - end; + if (!parse_header(c, end)) { fail(c, 400, "Bad Request", "request"); return; } + if (c->body_size > PURE_OBJECT_LIMIT) { fail(c, 413, "Content Too Large", "limit"); return; } + if (prefix > c->body_size) { fail(c, 400, "Bad Request", "extra_body"); return; } + if (!c->put) { run_request(c); return; } + if (!charge(c, c->body_size)) { fail(c, 503, "Service Unavailable", "payload_budget"); return; } + c->body = malloc(c->body_size ? c->body_size : 1); + if (!c->body) { fail(c, 503, "Service Unavailable", "allocation"); return; } + if (prefix) memcpy(c->body, c->request + end, prefix); + c->server->prefix_copy_bytes += prefix; + c->received = prefix; + c->phase = READING_BODY; + if (c->received == c->body_size) run_request(c); + } else { + size_t remaining = c->body_size - c->received; + ssize_t n = recv(c->fd, c->body + c->received, remaining < CHUNK ? remaining : CHUNK, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + c->received += (size_t)n; + if (c->received == c->body_size) run_request(c); + } +} + +static void write_connection(Connection *c) { + PureSpan span = pure_transfer_next(&c->transfer, CHUNK); + ssize_t n = send(c->fd, span.data, span.size, 0); + if (n < 0) { if (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) close_connection(c); return; } + if (!n) { close_connection(c); return; } + if (!pure_transfer_consume(&c->transfer, (size_t)n)) abort(); + if (!c->transfer.active) close_connection(c); +} + +static bool nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL, 0); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +} + +int main(int argc, char **argv) { + Server server = {.budget = 4u * 1024u * 1024u, .timeout_ms = 10000}; + unsigned port = 8767; + for (int i = 1; i < argc; i += 2) { + if (i + 1 == argc) goto usage; + uint64_t value; + if (!strcmp(argv[i], "--db")) server.path = argv[i + 1]; + else if (!strcmp(argv[i], "--port") && number(argv[i + 1], 65535, &value)) port = (unsigned)value; + else if (!strcmp(argv[i], "--budget") && number(argv[i + 1], SIZE_MAX, &value) && value) server.budget = (size_t)value; + else if (!strcmp(argv[i], "--timeout-ms") && number(argv[i + 1], 3600000, &value) && value) server.timeout_ms = value; + else if (!strcmp(argv[i], "--send-buffer") && number(argv[i + 1], INT32_MAX, &value)) server.send_buffer = (int)value; + else goto usage; + } + if (!server.path) goto usage; + if (pure_store_init(server.path) != PURE_OK) { fputs("Cannot initialize SQLite store\n", stderr); return 1; } + signal(SIGPIPE, SIG_IGN); + signal(SIGINT, stop_server); + signal(SIGTERM, stop_server); + int listener = socket(AF_INET, SOCK_STREAM, 0); + if (listener < 0) { perror("socket"); return 1; } + int one = 1; + setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + struct sockaddr_in address = {.sin_family = AF_INET, .sin_port = htons((uint16_t)port), + .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; + if (bind(listener, (struct sockaddr *)&address, sizeof(address)) || listen(listener, SLOTS) || !nonblocking(listener)) { + perror("listen"); close(listener); return 1; + } + socklen_t address_size = sizeof(address); + if (getsockname(listener, (struct sockaddr *)&address, &address_size)) { close(listener); return 1; } + for (size_t i = 0; i < SLOTS; ++i) server.connections[i].fd = -1; + printf("{\"port\":%u,\"sqlite\":\"%s\"}\n", ntohs(address.sin_port), sqlite3_libversion()); + fflush(stdout); + while (!stopping) { + struct pollfd polls[SLOTS + 1] = {{.fd = server.active < SLOTS ? listener : -1, .events = POLLIN}}; + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + polls[i + 1] = (struct pollfd){.fd = c->fd, .events = c->phase == WRITING ? POLLOUT : POLLIN}; + } + int ready = poll(polls, SLOTS + 1, 50); + if (ready < 0 && errno != EINTR) { perror("poll"); stopping = 1; } + uint64_t now = now_ms(); + for (size_t i = 0; i < SLOTS; ++i) { + Connection *c = &server.connections[i]; + if (c->fd < 0) continue; + if (now >= c->deadline) { ++server.expired; close_connection(c); continue; } + short events = polls[i + 1].revents; + if (events & (POLLERR | POLLNVAL)) { close_connection(c); continue; } + if (events & (POLLIN | POLLOUT | POLLHUP)) { + if (c->phase == WRITING) write_connection(c); else read_connection(c); + } + } + if (!stopping && (polls[0].revents & POLLIN)) { + int fd = accept(listener, NULL, NULL); + if (fd < 0) continue; + if (!nonblocking(fd)) { close(fd); continue; } + if (server.send_buffer) setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &server.send_buffer, sizeof(server.send_buffer)); + for (size_t i = 0; i < SLOTS; ++i) if (server.connections[i].fd < 0) { + server.connections[i] = (Connection){.fd = fd, .server = &server, .deadline = now_ms() + server.timeout_ms}; + ++server.active; + ++server.accepted; + if (server.active > server.peak_active) server.peak_active = server.active; + break; + } + } + } + close(listener); + for (size_t i = 0; i < SLOTS; ++i) close_connection(&server.connections[i]); + fprintf(stderr, "{\"active\":%zu,\"retained_payload_bytes\":%zu,\"peak_retained_payload_bytes\":%zu," + "\"payload_budget\":%zu,\"peak_connections\":%zu,\"accepted\":%" PRIu64 ",\"commits\":%" PRIu64 "," + "\"gets\":%" PRIu64 ",\"conflicts\":%" PRIu64 ",\"budget_rejections\":%" PRIu64 "," + "\"expired\":%" PRIu64 ",\"releases\":%" PRIu64 ",\"ingress_prefix_copy_bytes\":%" PRIu64 "," + "\"borrowed_output_bytes\":%" PRIu64 ",\"server_structure_bytes\":%zu}\n", + server.active, server.retained, server.peak_retained, server.budget, server.peak_active, + server.accepted, server.commits, server.gets, server.conflicts, server.budget_rejections, + server.expired, server.releases, server.prefix_copy_bytes, server.borrowed_output_bytes, sizeof(server)); + return 0; +usage: + fputs("pure-connections --db PATH [--port 8767] [--budget BYTES] [--timeout-ms 10000] [--send-buffer BYTES]\n", stderr); + return 2; +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/store.c b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/store.c new file mode 100644 index 0000000..a805f72 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/store.c @@ -0,0 +1,132 @@ +#include "store.h" +#include <limits.h> +#include <string.h> + +static PureResult error_result(int rc) { + int code = rc & 255; + return code == SQLITE_BUSY || code == SQLITE_LOCKED ? PURE_BUSY : PURE_STORE_ERROR; +} + +static int execute(sqlite3 *db, const char *sql) { + return sqlite3_exec(db, sql, NULL, NULL, NULL); +} + +static int open_db(const char *path, sqlite3 **db, int flags) { + int rc = sqlite3_open_v2(path, db, flags | SQLITE_OPEN_NOMUTEX, NULL); + if (rc != SQLITE_OK) return rc; + sqlite3_busy_timeout(*db, 0); /* No hidden wait/retry on this owner thread. */ + sqlite3_limit(*db, SQLITE_LIMIT_LENGTH, (int)PURE_OBJECT_LIMIT + 4096); + return execute(*db, "PRAGMA cache_size=-64; PRAGMA synchronous=FULL;"); +} + +PureResult pure_store_init(const char *path) { + sqlite3 *db = NULL; + int rc = open_db(path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE); + sqlite3_stmt *mode = NULL; + if (rc == SQLITE_OK) rc = sqlite3_prepare_v2(db, "PRAGMA journal_mode=WAL", -1, &mode, NULL); + if (rc == SQLITE_OK) { + rc = sqlite3_step(mode); + if (rc == SQLITE_ROW) { + const unsigned char *name = sqlite3_column_text(mode, 0); + rc = name && strcmp((const char *)name, "wal") == 0 ? SQLITE_OK : SQLITE_ERROR; + } + } + sqlite3_finalize(mode); + if (rc == SQLITE_OK) rc = execute(db, + "CREATE TABLE IF NOT EXISTS pure_objects(" + "key TEXT PRIMARY KEY, revision INTEGER NOT NULL CHECK(revision>0)," + "body BLOB NOT NULL CHECK(typeof(body)='blob'));"); + if (db) sqlite3_close(db); + return rc == SQLITE_OK ? PURE_OK : error_result(rc); +} + +void pure_store_release(PureStoredView *view) { + sqlite3_finalize(view->statement); + if (view->db) { + execute(view->db, "ROLLBACK"); + sqlite3_close(view->db); + } + *view = (PureStoredView){0}; +} + +PureResult pure_store_get(const char *path, const char *key, size_t available, + PureStoredView *out) { + *out = (PureStoredView){0}; + int rc = open_db(path, &out->db, SQLITE_OPEN_READONLY); + if (rc == SQLITE_OK) rc = execute(out->db, "BEGIN"); + if (rc == SQLITE_OK) rc = sqlite3_prepare_v2(out->db, + "SELECT revision,length(body) FROM pure_objects WHERE key=?", -1, &out->statement, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(out->statement, 1, key, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(out->statement); + PureResult result = PURE_STORE_ERROR; + if (rc == SQLITE_DONE) { result = PURE_NOT_FOUND; goto fail; } + if (rc != SQLITE_ROW) { result = error_result(rc); goto fail; } + out->revision = sqlite3_column_int64(out->statement, 0); + sqlite3_int64 size = sqlite3_column_int64(out->statement, 1); + if (out->revision <= 0 || size < 0) goto fail; + if (size > PURE_OBJECT_LIMIT) { result = PURE_LIMIT; goto fail; } + if ((uint64_t)size > available) { result = PURE_BUDGET; goto fail; } + sqlite3_finalize(out->statement); + out->statement = NULL; + /* Keep the read transaction so admission and body observe the same version. + * The result remains borrowed until socket completion/abort finalizes it. */ + rc = sqlite3_prepare_v2(out->db, "SELECT body FROM pure_objects WHERE key=?", -1, &out->statement, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(out->statement, 1, key, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(out->statement); + if (rc != SQLITE_ROW) { result = error_result(rc); goto fail; } + if (sqlite3_column_type(out->statement, 0) != SQLITE_BLOB) goto fail; + const unsigned char *data = sqlite3_column_blob(out->statement, 0); + int bytes = sqlite3_column_bytes(out->statement, 0); + if (bytes != size || (bytes && !data)) goto fail; + out->body = (PureSpan){data, (size_t)bytes}; + return PURE_OK; +fail: + pure_store_release(out); + return result; +} + +PureResult pure_store_put(const char *path, const char *key, int64_t expected, + PureSpan body, int64_t *revision) { + *revision = 0; + if (body.size > PURE_OBJECT_LIMIT || expected < 0) return PURE_LIMIT; + sqlite3 *db = NULL; + sqlite3_stmt *statement = NULL; + PureResult result = PURE_STORE_ERROR; + int rc = open_db(path, &db, SQLITE_OPEN_READWRITE); + if (rc == SQLITE_OK) rc = execute(db, "BEGIN IMMEDIATE"); + if (rc != SQLITE_OK) { result = error_result(rc); goto finish; } + rc = sqlite3_prepare_v2(db, "SELECT revision FROM pure_objects WHERE key=?", -1, &statement, NULL); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(statement, 1, key, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(statement); + if (rc == SQLITE_ROW) *revision = sqlite3_column_int64(statement, 0); + else if (rc != SQLITE_DONE) { result = error_result(rc); goto finish; } + sqlite3_finalize(statement); + statement = NULL; + if (*revision != expected) { result = PURE_CONFLICT; goto finish; } + if (*revision == INT64_MAX) { result = PURE_LIMIT; goto finish; } + const char *sql = *revision ? + "UPDATE pure_objects SET body=?1,revision=?2 WHERE key=?3" : + "INSERT INTO pure_objects(body,revision,key) VALUES(?1,?2,?3)"; + rc = sqlite3_prepare_v2(db, sql, -1, &statement, NULL); + static const unsigned char empty = 0; + /* Do not copy at the binding boundary. Finalize before returning the input. */ + if (rc == SQLITE_OK) rc = sqlite3_bind_blob(statement, 1, body.size ? body.data : &empty, + (int)body.size, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_bind_int64(statement, 2, *revision + 1); + if (rc == SQLITE_OK) rc = sqlite3_bind_text(statement, 3, key, -1, SQLITE_STATIC); + if (rc == SQLITE_OK) rc = sqlite3_step(statement); + if (rc != SQLITE_DONE) { result = error_result(rc); goto finish; } + rc = sqlite3_finalize(statement); + statement = NULL; + if (rc == SQLITE_OK) rc = execute(db, "COMMIT"); + if (rc != SQLITE_OK) { result = error_result(rc); goto finish; } + ++*revision; + result = PURE_OK; /* Acknowledgment is constructed only after COMMIT. */ +finish: + sqlite3_finalize(statement); + if (db) { + if (!sqlite3_get_autocommit(db)) execute(db, "ROLLBACK"); + sqlite3_close(db); + } + return result; +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/store.h b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/store.h new file mode 100644 index 0000000..c0e9f96 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/store.h @@ -0,0 +1,25 @@ +#ifndef PURE_STORE_H +#define PURE_STORE_H +#include "flow.h" +#include <sqlite3.h> +#include <stdint.h> + +#define PURE_OBJECT_LIMIT ((2u * 6u + 32u) * 32768u + 8192u) +typedef enum { + PURE_OK, PURE_NOT_FOUND, PURE_CONFLICT, PURE_BUSY, PURE_BUDGET, + PURE_LIMIT, PURE_STORE_ERROR +} PureResult; +typedef struct { + sqlite3 *db; + sqlite3_stmt *statement; + PureSpan body; + int64_t revision; +} PureStoredView; + +PureResult pure_store_init(const char *path); +PureResult pure_store_get(const char *path, const char *key, size_t available, + PureStoredView *out); +void pure_store_release(PureStoredView *); +PureResult pure_store_put(const char *path, const char *key, int64_t expected, + PureSpan body, int64_t *revision); +#endif diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/test-flow.c b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/test-flow.c new file mode 100644 index 0000000..997e7df --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/connections/test-flow.c @@ -0,0 +1,110 @@ +#define _POSIX_C_SOURCE 200809L +#include "store.h" +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <unistd.h> + +#define CHECK(expr) do { if (!(expr)) { fprintf(stderr, "line %d: %s\n", __LINE__, #expr); exit(1); } } while (0) +typedef struct { unsigned char *bytes; unsigned releases; } Owner; +static void release(void *raw) { + Owner *owner = raw; + free(owner->bytes); + owner->bytes = NULL; + ++owner->releases; +} + +static void transfers(void) { + Owner owner = {.bytes = malloc(5)}; + CHECK(owner.bytes); + memcpy(owner.bytes, "a\0bcd", 5); + PureTransfer transfer = {0}; + pure_transfer_init(&transfer, (PureSpan){(const unsigned char *)"head", 4}, + (PureSpan){owner.bytes, 5}, &owner, release); + CHECK(!pure_transfer_consume(&transfer, 5)); + CHECK(transfer.active && !owner.releases); + CHECK(pure_transfer_consume(&transfer, 0)); + unsigned char received[9]; + size_t used = 0; + while (transfer.active) { + PureSpan span = pure_transfer_next(&transfer, 2); + CHECK(span.size && span.size <= 2); + memcpy(received + used, span.data, span.size); + used += span.size; + CHECK(pure_transfer_consume(&transfer, span.size)); + CHECK((used == 9) == (owner.releases == 1)); + } + CHECK(used == 9 && !memcmp(received, "heada\0bcd", 9)); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 1 && !pure_transfer_consume(&transfer, 0)); + owner.bytes = malloc(3); + CHECK(owner.bytes); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){owner.bytes, 3}, &owner, release); + CHECK(pure_transfer_consume(&transfer, 1)); + pure_transfer_abort(&transfer); + pure_transfer_abort(&transfer); + CHECK(owner.releases == 2 && !transfer.active); + pure_transfer_init(&transfer, (PureSpan){0}, (PureSpan){0}, &owner, release); + CHECK(owner.releases == 3 && !transfer.active); +} + +static void storage(const char *path) { + CHECK(pure_store_init(path) == PURE_OK); + PureStoredView first, second; + CHECK(pure_store_get(path, "missing", 100, &first) == PURE_NOT_FOUND); + CHECK(!first.db && !first.statement); + static const unsigned char original[] = {0, 1, 2, 255, 17}; + static const unsigned char updated[] = {9, 8, 7}; + int64_t revision; + CHECK(pure_store_put(path, "work", 0, (PureSpan){original, sizeof(original)}, &revision) == PURE_OK); + CHECK(revision == 1); + CHECK(pure_store_get(path, "work", sizeof(original) - 1, &first) == PURE_BUDGET); + CHECK(!first.db && !first.statement); + CHECK(pure_store_get(path, "work", sizeof(original), &first) == PURE_OK); + CHECK(first.body.size == sizeof(original) && first.revision == 1); + CHECK(pure_store_put(path, "work", 1, (PureSpan){updated, sizeof(updated)}, &revision) == PURE_OK); + CHECK(revision == 2); + CHECK(!memcmp(first.body.data, original, sizeof(original))); + CHECK(pure_store_get(path, "work", 100, &second) == PURE_OK); + CHECK(second.revision == 2 && second.body.size == sizeof(updated)); + CHECK(!memcmp(second.body.data, updated, sizeof(updated))); + pure_store_release(&first); + pure_store_release(&first); + pure_store_release(&second); + CHECK(pure_store_put(path, "work", 1, (PureSpan){original, sizeof(original)}, &revision) == PURE_CONFLICT); + CHECK(revision == 2); + + sqlite3 *other = NULL; + CHECK(sqlite3_open(path, &other) == SQLITE_OK); + CHECK(sqlite3_exec(other, "BEGIN IMMEDIATE", NULL, NULL, NULL) == SQLITE_OK); + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_BUSY); + CHECK(sqlite3_exec(other, "ROLLBACK", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(other, "CREATE TRIGGER deny BEFORE UPDATE ON pure_objects " + "BEGIN SELECT RAISE(ABORT,'injected failure'); END", NULL, NULL, NULL) == SQLITE_OK); + CHECK(pure_store_put(path, "work", 2, (PureSpan){original, sizeof(original)}, &revision) == PURE_STORE_ERROR); + CHECK(pure_store_get(path, "work", 100, &first) == PURE_OK); + CHECK(first.revision == 2 && !memcmp(first.body.data, updated, sizeof(updated))); + pure_store_release(&first); + CHECK(sqlite3_exec(other, "DROP TRIGGER deny; UPDATE pure_objects SET revision=9223372036854775807", NULL, NULL, NULL) == SQLITE_OK); + CHECK(pure_store_put(path, "work", INT64_MAX, (PureSpan){original, sizeof(original)}, &revision) == PURE_LIMIT); + CHECK(sqlite3_close(other) == SQLITE_OK); + CHECK(pure_store_put(path, "empty", 0, (PureSpan){0}, &revision) == PURE_OK); + CHECK(pure_store_get(path, "empty", 0, &first) == PURE_OK && first.body.size == 0); + pure_store_release(&first); +} + +int main(void) { + transfers(); + char directory[] = "/tmp/pure-connection-unit-XXXXXX"; + CHECK(mkdtemp(directory)); + char path[256], sidecar[280]; + snprintf(path, sizeof(path), "%s/store.db", directory); + storage(path); + CHECK(unlink(path) == 0); + snprintf(sidecar, sizeof(sidecar), "%s-wal", path); unlink(sidecar); + snprintf(sidecar, sizeof(sidecar), "%s-shm", path); unlink(sidecar); + CHECK(rmdir(directory) == 0); + puts("{\"transfer\":\"partial-consumption-and-single-release\"," + "\"storage\":\"snapshot-budget-conflict-busy-rollback-overflow-empty\"}"); + return 0; +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/cli.mjs b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/cli.mjs new file mode 100644 index 0000000..b67f4d3 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/cli.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +import { readFile, writeFile } from 'node:fs/promises'; +import { createInterface } from 'node:readline'; +import { createRuntime } from './web/runtime.mjs'; +import { parseProject } from './web/project.mjs'; +import { remoteTarget, pushProject, pullProject } from './remote.mjs'; + +const help = `Pure authoring workbench · 같은 정의와 Zig 실행기 + load PATH .pure 파일을 적용 (같은 이름의 상태 보존) + down NAME 정의한 입력 누르기 + up NAME 정의한 입력 놓기 + point NAME PHASE X Y 좌표 입력 (down / move / up / cancel) + point-target NAME VIEW PHASE X Y 그룹의 대상에 좌표 입력 + change NAME VALUE 숫자 입력 전달 + frame [1..600] 시간 진행, 1프레임 = 1/60초 + show 상태와 도형 출력 + limits C H W 표시 캐시(0..32)·변경 기록(0..8)·작업량(0..4294967295) 설정 + forget [PART] 표시 기록 삭제: all, values, history (상태는 유지) + costs 마지막 표시 계산의 작업량·명령 수·캐시 적중 출력 + reset 현재 정의의 초기 상태로 + save PATH 적용한 정의를 저장 + save-project PATH 적용 코드와 현재 배치를 JSON으로 저장 + restore PATH 저장한 배치를 복원 (초안이 있으면 파일에 보존) + connect URL KEY 로컬 네이티브 저장 서버의 작업물에 연결 + push [REVISION] 현재 배치를 저장 (첫 생성은 push 0, 충돌 시 기존 값 유지) + pull 원격 배치를 검증·복원하고 revision 기억 + help / quit +브라우저와 별도 인스턴스입니다. 임의 JavaScript는 실행하지 않습니다.`; + +async function main() { + const args = process.argv.slice(2); + if (args.includes('--help')) { console.log(help); return; } + if (args.length > 1) throw new Error('사용법: node cli.mjs [project.pure]'); + const runtime = await createRuntime(await readFile(new URL('./web/core.wasm', import.meta.url))); + let savedDraft = null; + let remote = null, remoteRevision = null; + async function loadFile(path, restoreOnly = false) { + const content = await readFile(path, 'utf8'); + if (restoreOnly || path.toLowerCase().endsWith('.json')) { + const project = parseProject(content); + if (project.version === 2) { + savedDraft = runtime.restoreDocument(project).draft; + return; + } + if (restoreOnly) throw new Error('현재 배치가 포함된 version 2 파일이 필요합니다.'); + runtime.apply(project.source); + } else runtime.apply(content); + savedDraft = null; + } + if (args[0]) await loadFile(args[0]); else runtime.apply(''); + const terminal = createInterface({ input: process.stdin, output: process.stdout, terminal: Boolean(process.stdin.isTTY) }); + if (process.stdin.isTTY) console.log(help); + terminal.setPrompt('pure> '); + if (process.stdin.isTTY) terminal.prompt(); + for await (const line of terminal) { + try { + const match = /^(\S+)(?:\s+(.+))?$/.exec(line.trim()); + const name = match?.[1] || 'show'; + const arg = match?.[2]; + if (name === 'quit') { terminal.close(); break; } + if (name === 'help') console.log(help); + else if (name === 'costs' && !arg) console.log(JSON.stringify({ evaluation: runtime.evaluationStats() })); + else { + if (name === 'load' && arg) await loadFile(arg); + else if (name === 'restore' && arg) await loadFile(arg, true); + else if (name === 'connect' && arg) { + const parts = arg.split(/\s+/); + if (parts.length !== 2) throw new Error('connect URL KEY 형식을 사용하세요.'); + remote = remoteTarget(parts[0], parts[1]); + remoteRevision = null; + console.log(JSON.stringify({ remote: remote.href })); + } else if (name === 'push') { + if (!remote) throw new Error('먼저 connect URL KEY로 연결하세요.'); + const expected = arg ?? remoteRevision; + if (expected === null) throw new Error('먼저 pull로 버전을 읽거나 push 0으로 새 작업물을 만드세요.'); + remoteRevision = await pushProject(remote, expected, runtime.exportDocument(savedDraft ?? undefined)); + console.log(JSON.stringify({ remote: remote.href, revision: remoteRevision, stored: true })); + } else if (name === 'pull' && !arg) { + if (!remote) throw new Error('먼저 connect URL KEY로 연결하세요.'); + const loaded = await pullProject(remote); + savedDraft = runtime.restoreDocument(loaded.project).draft; + remoteRevision = loaded.revision; + console.log(JSON.stringify({ remote: remote.href, revision: remoteRevision, restored: true })); + } + else if (name === 'save-project' && arg) await writeFile(arg, JSON.stringify(runtime.exportDocument(savedDraft ?? undefined), null, 2) + '\n', {flag:'wx'}); + else if (name === 'save' && arg) await writeFile(arg, runtime.exportProject().source, { flag: 'wx' }); + else if (name === 'down' && arg) runtime.down(arg, 'terminal'); + else if (name === 'up' && arg) runtime.up(arg, 'terminal'); + else if (name === 'point' && arg) { + const parts = arg.split(/\s+/); + if (parts.length !== 4) throw new Error('point NAME PHASE X Y 형식을 사용하세요.'); + runtime.point(parts[0], parts[1], Number(parts[2]), Number(parts[3]), 'terminal'); + } else if (name === 'point-target' && arg) { + const parts = arg.split(/\s+/); + if (parts.length !== 5) throw new Error('point-target NAME VIEW PHASE X Y 형식을 사용하세요.'); + runtime.point(parts[0], parts[2], Number(parts[3]), Number(parts[4]), 'terminal', parts[1]); + } else if (name === 'change' && arg) { + const parts = arg.split(/\s+/); + if (parts.length !== 2) throw new Error('change NAME VALUE 형식을 사용하세요.'); + runtime.change(parts[0], Number(parts[1]), 'terminal'); + } else if (name === 'frame') { + if (arg !== undefined && !/^\d+$/.test(arg)) throw new Error('frame 1..600 정수를 입력하세요.'); + const count = arg === undefined ? 1 : Number(arg); + if (count < 1 || count > 600) throw new Error('frame 1..600 정수를 입력하세요.'); + runtime.frame(count); + } else if (name === 'reset' && !arg) runtime.reset(); + else if (name === 'limits' && arg) { + const parts = arg.split(/\s+/); + if (parts.length !== 3 || parts.some(part => !/^\d+$/.test(part))) + throw new Error('limits C H W 정수 세 개를 입력하세요.'); + const [cacheEntries, historyEntries, workLimit] = parts.map(Number); + runtime.configureEvaluation({ cacheEntries, historyEntries, workLimit }); + } else if (name === 'forget') runtime.forgetEvaluation(arg || 'all'); + else if (name !== 'show' || arg) throw new Error('명령 형식을 확인하세요. help로 안내를 볼 수 있습니다.'); + const result = runtime.snapshot(); + console.log(JSON.stringify(result)); + if (result.lastError && !process.stdin.isTTY) process.exitCode = 1; + } + } catch (error) { + console.error(`${error.line ? `${error.line}:${error.column || 1} ` : ''}${error.message}`); + if (!process.stdin.isTTY) process.exitCode = 1; + } + if (process.stdin.isTTY) terminal.prompt(); + } +} + +main().catch((error) => { console.error(error.message); process.exitCode = 1; }); diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/cli.test.mjs b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/cli.test.mjs new file mode 100644 index 0000000..f355d2d --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/cli.test.mjs @@ -0,0 +1,121 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRuntime } from './web/runtime.mjs'; + +const root = fileURLToPath(new URL('.', import.meta.url)); +function run(input, file) { + const result = spawnSync(process.execPath, [join(root, 'cli.mjs'), ...(file ? [file] : [])], { + input, encoding: 'utf8', cwd: root, + }); + if (result.error) throw result.error; + return { ...result, snapshots: result.stdout.split('\n').filter(line => line.startsWith('{')).map(line => JSON.parse(line)) }; +} + +test('terminal starts empty and produces the same charge trace as the shared runtime', async () => { + assert.deepEqual(run('show\nquit\n').snapshots[0].states, {}); + const file = join(root, 'examples/charge.pure'); + const result = run('down jump\nframe 30\nup jump\nframe 1\nshow\nquit\n', file); + assert.equal(result.status, 0, result.stderr); + const runtime = await createRuntime(await readFile(join(root, 'web/core.wasm'))); + runtime.apply(await readFile(file, 'utf8')); + runtime.down('jump', 'terminal'); runtime.frame(30); runtime.up('jump', 'terminal'); runtime.frame(); + // JSON has no undefined-valued optional fields; compare the same wire form. + assert.deepEqual(result.snapshots.at(-1), JSON.parse(JSON.stringify(runtime.snapshot()))); + assert(result.snapshots.at(-1).states.y < 240); +}); + +test('invalid terminal commands do not alter state and fail piped execution', () => { + const result = run('show\nframe 0\nframe -1\nframe 1.5\nframe 1e2\nframe 601\nshow extra\ndown unknown\nshow\nquit\n', join(root, 'examples/counter.pure')); + assert.equal(result.status, 1); + assert.deepEqual(result.snapshots[0], result.snapshots[1]); + assert.equal(result.stderr.trim().split('\n').length, 7); +}); + +test('terminal replays authored pointer movement and cancellation through the shared runtime', async () => { + const file = join(root, 'examples/direct.pure'); + const result = run('point drag down 90 170\npoint drag move 360 220\npoint drag up 410 240\npoint drag down 410 240\npoint drag move 500 260\npoint drag cancel 500 260\nshow\nquit\n', file); + assert.equal(result.status, 0, result.stderr); + const runtime = await createRuntime(await readFile(join(root, 'web/core.wasm'))); + runtime.apply(await readFile(file, 'utf8')); + for (const [phase, x, y] of [['down',90,170],['move',360,220],['up',410,240],['down',410,240],['move',500,260],['cancel',500,260]]) + runtime.point('drag', phase, x, y, 'terminal'); + assert.deepEqual(result.snapshots.at(-1), JSON.parse(JSON.stringify(runtime.snapshot()))); + assert.equal(result.snapshots.at(-1).states.x, 350); + assert.equal(result.snapshots.at(-1).states.y, 210); + assert.deepEqual(result.snapshots.at(-1).held, []); +}); + +test('file load preserves by name, rejected load preserves active work, and save never overwrites', async t => { + const directory = await mkdtemp(join(tmpdir(), 'pure-author-cli-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const first = join(directory, 'first.pure'), second = join(directory, 'second with space.pure'); + const invalid = join(directory, 'invalid.pure'), saved = join(directory, 'saved.pure'); + await writeFile(first, 'state n = 7'); + await writeFile(second, 'state n = 100\nstate added = 9'); + await writeFile(invalid, 'state n = broken'); + const result = run(`load ${second}\nload ${invalid}\nshow\nsave ${saved}\nreset\nsave ${saved}\nquit\n`, first); + assert.equal(result.status, 1); + assert.deepEqual(result.snapshots[0].states, { n: 7, added: 9 }); + assert.deepEqual(result.snapshots[1], result.snapshots[0]); + assert.deepEqual(result.snapshots.at(-1).states, { n: 100, added: 9 }); + assert.equal(await readFile(saved, 'utf8'), await readFile(second, 'utf8')); + assert.match(result.stderr, /EEXIST/); +}); + +test('terminal exposes arithmetic rollback and retains failure through matching release', async t => { + const directory = await mkdtemp(join(tmpdir(), 'pure-author-error-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const file = join(directory, 'error.pure'); + await writeFile(file, 'state n = 7\ninput tap = Space\non tap.down { n = 1 / 0 }'); + const result = run('down tap\nup tap\nshow\nreset\nquit\n', file); + assert.equal(result.status, 1); + for (const snapshot of result.snapshots.slice(0, 3)) { + assert.equal(snapshot.states.n, 7); assert.equal(snapshot.lastError, 4); + assert.equal(snapshot.errorLine, 3); + } + assert.equal(result.snapshots[3].lastError, 0); +}); + +test('terminal configures and discards evaluation records and recovers from a display limit', () => { + const result = run('limits 32 8 2048\nshow\ncosts\nforget all\nlimits 32 8 0\nlimits 32 8 2048\nshow\nquit\n', join(root, 'examples/counter.pure')); + assert.equal(result.status, 1); // The explicit zero-budget display failed. + assert.equal(result.stderr, ''); + const [configured, warm, costs, forgotten, limited, recovered, final] = result.snapshots; + assert.deepEqual(warm.views, configured.views); + assert(costs.evaluation.cacheHits > 0); + assert.deepEqual(forgotten.states, configured.states); + assert.deepEqual(limited.views, []); + assert.equal(limited.lastError, 101); + assert.deepEqual(recovered, final); + assert.deepEqual(recovered.views, configured.views); + assert.deepEqual(recovered.states, configured.states); +}); + +test('terminal saves and restores placement independently of reset and preserves a browser draft', async t => { + const directory = await mkdtemp(join(tmpdir(), 'pure-layout-cli-')); + t.after(() => rm(directory, {recursive:true,force:true})); + const saved=join(directory,'layout.json'), copied=join(directory,'copy.json'); + const first=run(`point drag down 90 170\npoint drag move 360 220\npoint drag up 90 170\nsave-project ${saved}\nreset\nrestore ${saved}\nshow\nquit\n`,join(root,'examples/direct.pure')); + assert.equal(first.status,0,first.stderr);assert.equal(first.snapshots.at(-1).states.x,350); + const document=JSON.parse(await readFile(saved,'utf8'));document.draft='state incomplete ='; + await writeFile(saved,JSON.stringify(document)); + const second=run(`save-project ${copied}\nreset\nshow\nquit\n`,saved); + assert.equal(second.status,0,second.stderr);assert.equal(second.snapshots.at(-1).states.x,80); + const copy=JSON.parse(await readFile(copied,'utf8'));assert.equal(copy.draft,document.draft);assert.deepEqual(copy.states,document.states); + const rejected=run(`save-project ${saved}\nquit\n`,saved); + assert.equal(rejected.status,1);assert.match(rejected.stderr,/EEXIST/); +}); + +test('invalid restoration leaves terminal code and state unchanged', async t => { + const directory=await mkdtemp(join(tmpdir(),'pure-invalid-layout-')); + t.after(()=>rm(directory,{recursive:true,force:true})); + const file=join(directory,'bad.json'); + await writeFile(file,JSON.stringify({format:'pure-workbench',version:2,source:'state x=1',states:[]})); + const result=run(`show\nrestore ${file}\nshow\nquit\n`,join(root,'examples/counter.pure')); + assert.equal(result.status,1);assert.deepEqual(result.snapshots[0],result.snapshots[1]); +}); diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/examples/direct.pure b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/examples/direct.pure new file mode 100644 index 0000000..b29721c --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/examples/direct.pure @@ -0,0 +1,47 @@ +# 도형을 잡은 위치를 유지해 이동합니다. 취소하면 잡기 전 위치로 돌아갑니다. +state x = 80 +state y = 160 +state grab_x = 0 +state grab_y = 0 +state start_x = 80 +state start_y = 160 +state dragging = 0 + +input drag = pointer(tile) +input left = ArrowLeft +input right = ArrowRight + +box left_area(20, 100, 280, 220, "#233a31") +box right_area(340, 100, 280, 220, "#343b25") +box tile(x, y, 56, 44, "#c9f08b") +text horizontal(30, 45, x, "x: ") +text vertical(180, 45, y, "y: ") +text region(350, 70, x >= 320, "오른쪽 영역: ") +text active(30, 75, dragging, "잡고 있음: ") + +on drag.down { + start_x = x + start_y = y + grab_x = event_x - x + grab_y = event_y - y + dragging = 1 +} +on drag.move { + x = clamp(event_x - grab_x, 20, 564) + y = clamp(event_y - grab_y, 100, 276) +} +on drag.up { + # 놓기는 마지막으로 표시한 위치를 확정합니다. + dragging = 0 +} +on drag.cancel { + x = start_x + y = start_y + dragging = 0 +} +on left.down { + if dragging == 0 { x = max(20, x - 10) } +} +on right.down { + if dragging == 0 { x = min(564, x + 10) } +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/remote.mjs b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/remote.mjs new file mode 100644 index 0000000..6ee6a59 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/remote.mjs @@ -0,0 +1,89 @@ +import http from 'node:http'; +import { MAX_PROJECT_BYTES, parseProject } from './web/project.mjs'; + +const maxRevision = 9223372036854775807n; +function revisionText(value, allowZero = false) { + if (typeof value !== 'string' || !/^(0|[1-9]\d*)$/.test(value) || + value.length > 19 || BigInt(value) > maxRevision || (!allowZero && value === '0')) + throw new Error('revision은 범위 안의 정수 문자열이어야 합니다.'); + return value; +} + +export function remoteTarget(base, key) { + const url = new URL(base); + if (url.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(url.hostname) || + url.username || url.password || url.pathname !== '/' || url.search || url.hash) + throw new Error('현재 연결 호스트는 로컬 http://127.0.0.1:PORT 형식을 사용합니다.'); + if (!/^[A-Za-z0-9_-]{1,64}$/.test(key)) throw new Error('작업물 이름은 영문·숫자·_·- 1~64자입니다.'); + url.hostname = '127.0.0.1'; + url.pathname = `/objects/${key}`; + return url; +} + +function request(url, method, body, headers = {}) { + return new Promise((resolve, reject) => { + let done = false; + const finish = (error, value) => { + if (done) return; + done = true; + clearTimeout(deadline); + if (error) reject(error); else resolve(value); + }; + const req = http.request(url, { method, agent: false, headers: { ...headers, + 'Content-Length': body?.byteLength ?? 0, Connection: 'close' } }, (res) => { + const chunks = []; + let size = 0; + res.on('data', (chunk) => { + size += chunk.length; + if (size > MAX_PROJECT_BYTES) { + const error = new Error('서버 작업물이 허용 크기를 넘었습니다.'); + finish(error); + res.destroy(error); + req.destroy(error); + } else chunks.push(chunk); + }); + res.on('error', (error) => finish(error)); + res.on('aborted', () => finish(new Error('응답이 완료되기 전에 연결이 끊겼습니다.'))); + res.on('end', () => { + if (res.statusCode !== 200) { + const error = new Error(`원격 작업 실패: ${res.statusCode} ${res.headers['x-pure-error'] ?? ''}`.trim()); + error.status = res.statusCode; + error.etag = res.headers.etag; + finish(error); + return; + } + try { + const match = /^"([1-9]\d*)"$/.exec(res.headers.etag ?? ''); + if (!match) throw new Error('서버의 revision 응답이 올바르지 않습니다.'); + const revision = revisionText(match[1]); + finish(null, { revision, bytes: Buffer.concat(chunks, size) }); + } catch (error) { finish(error); } + }); + }); + const deadline = setTimeout(() => { + const error = new Error('원격 작업 시간 초과: 저장 완료 여부는 다시 확인해야 합니다.'); + finish(error); + req.destroy(error); + }, 12000); + req.on('error', (error) => finish(error)); + req.end(body); + }); +} + +export async function pushProject(target, expected, project) { + revisionText(expected, true); + const text = JSON.stringify(project); + parseProject(text); + const body = Buffer.from(text, 'utf8'); + const headers = expected === '0' ? { 'If-None-Match': '*' } : { 'If-Match': `"${expected}"` }; + const result = await request(target, 'PUT', body, headers); + return result.revision; +} + +export async function pullProject(target) { + const result = await request(target, 'GET'); + const text = new TextDecoder('utf-8', { fatal: true }).decode(result.bytes); + const project = parseProject(text); + if (project.version !== 2) throw new Error('현재 배치를 포함한 version 2 작업물이 필요합니다.'); + return { project, revision: result.revision }; +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/compiler.mjs b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/compiler.mjs new file mode 100644 index 0000000..6aec335 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/compiler.mjs @@ -0,0 +1,496 @@ +// Pure's experimental scalar interaction language. No host code is evaluated. +const LIMITS = Object.freeze({ sourceBytes: 32768, states: 32, inputs: 8, + views: 24, handlers: 64, instructions: 512, depth: 64, stack: 64 }); +const RESERVED = new Set(['frame', 'dt', 'event_x', 'event_y', 'event_value', 'min', 'max', 'clamp']); +const BINARY = new Map([ + ['||', [1, 15]], ['&&', [2, 14]], ['==', [3, 12]], ['!=', [3, 13]], + ['<', [4, 8]], ['<=', [4, 9]], ['>', [4, 10]], ['>=', [4, 11]], + ['+', [5, 3]], ['-', [5, 4]], ['*', [6, 5]], ['/', [6, 6]], +]); +const FUNCTIONS = { min: [2, 17], max: [2, 18], clamp: [3, 19] }; +const VIEW_FIELDS = { box: ['x', 'y', 'width', 'height'], + circle: ['x', 'y', 'radius'], text: ['x', 'y', 'value'] }; + +function fail(token, message) { + const error = new Error(`${message} (${token.line}:${token.column})`); + error.line = token.line; + error.column = token.column; + throw error; +} + +function lex(source) { + const tokens = []; + let offset = 0, line = 1, column = 1; + const advance = () => { + const char = source[offset++]; + if (char === '\n') { line++; column = 1; } else column++; + return char; + }; + while (offset < source.length) { + const char = source[offset]; + if (char === ' ' || char === '\t' || char === '\r') { advance(); continue; } + if (char === '#') { while (offset < source.length && source[offset] !== '\n') advance(); continue; } + const token = { line, column }; + if (char === '\n') { advance(); tokens.push({ ...token, kind: 'nl' }); continue; } + if (char === '"') { + advance(); + let value = '', closed = false; + while (offset < source.length) { + const next = advance(); + if (next === '"') { closed = true; break; } + if (next === '\n' || next === '\r') fail(token, 'Strings must fit on one line'); + if (next === '\\') { + const escaped = advance(); + const escapes = { n: '\n', r: '\r', t: '\t', '"': '"', '\\': '\\' }; + if (!Object.hasOwn(escapes, escaped)) fail(token, 'Unsupported string escape'); + value += escapes[escaped]; + } else value += next; + } + if (!closed) fail(token, 'Unterminated string'); + tokens.push({ ...token, kind: 'string', value }); continue; + } + const remaining = source.slice(offset); + const number = remaining.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/); + if (number) { + const value = Number(number[0]); + if (!Number.isFinite(value)) fail(token, 'Number must be finite'); + for (let i = 0; i < number[0].length; i++) advance(); + tokens.push({ ...token, kind: 'number', value }); continue; + } + const name = remaining.match(/^[A-Za-z_][A-Za-z_0-9]*/); + if (name) { + for (let i = 0; i < name[0].length; i++) advance(); + tokens.push({ ...token, kind: 'name', value: name[0] }); continue; + } + const pair = source.slice(offset, offset + 2); + if (['<=', '>=', '==', '!=', '&&', '||'].includes(pair)) { + advance(); advance(); tokens.push({ ...token, kind: pair }); continue; + } + if ('=+-*/!<>(){},.;:'.includes(char)) { + advance(); tokens.push({ ...token, kind: char }); continue; + } + fail(token, `Unexpected character ${JSON.stringify(char)}`); + } + tokens.push({ kind: 'eof', line, column }); + return tokens; +} + +class Parser { + constructor(tokens) { this.tokens = tokens; this.position = 0; this.depth = 0; } + peek() { return this.tokens[this.position]; } + take() { return this.tokens[this.position++]; } + is(kind, value) { return this.peek().kind === kind && (value === undefined || this.peek().value === value); } + expect(kind, description = kind) { + if (!this.is(kind)) fail(this.peek(), `Expected ${description}`); + return this.take(); + } + newlines() { while (this.is('nl')) this.take(); } + separators() { while (this.is('nl') || this.is(';')) this.take(); } + end() { + if (!['nl', ';', '}', 'eof'].includes(this.peek().kind)) fail(this.peek(), 'Expected newline or semicolon'); + this.separators(); + } + reference(token) { + if (!this.is('.')) return token.value; + this.take(); + return `${token.value}.${this.expect('name', 'field name').value}`; + } + initial() { + let sign = 1; + if (this.is('-') || this.is('+')) sign = this.take().kind === '-' ? -1 : 1; + return sign * this.expect('number', 'numeric state initializer').value; + } + node(token, properties, children = []) { + const depth = 1 + Math.max(0, ...children.map(child => child.depth)); + if (depth > LIMITS.depth) fail(token, 'Expression nesting exceeds 64'); + return { token, depth, ...properties }; + } + expression(minimum = 0, multiline = false) { + if (++this.depth > LIMITS.depth) fail(this.peek(), 'Expression nesting exceeds 64'); + if (multiline) this.newlines(); + const token = this.take(); + let left; + if (token.kind === 'number') left = this.node(token, { kind: 'constant', value: token.value }); + else if (token.kind === '-' || token.kind === '!') { + const child = this.expression(7, multiline); + left = this.node(token, { kind: 'unary', op: token.kind === '-' ? 7 : 16, child }, [child]); + } else if (token.kind === '(') { + left = this.expression(0, true); this.newlines(); this.expect(')'); + } else if (token.kind === 'name') { + if (Object.hasOwn(FUNCTIONS, token.value) && this.is('(')) { + this.take(); + const args = []; + this.newlines(); + if (!this.is(')')) { + for (;;) { + args.push(this.expression(0, true)); this.newlines(); + if (!this.is(',')) break; + this.take(); + } + } + this.expect(')'); + if (args.length !== FUNCTIONS[token.value][0]) fail(token, `${token.value} expects ${FUNCTIONS[token.value][0]} arguments`); + left = this.node(token, { kind: 'call', op: FUNCTIONS[token.value][1], args }, args); + } else left = this.node(token, { kind: 'reference', name: this.reference(token) }); + } else fail(token, 'Expected a number, state, or expression'); + for (;;) { + if (multiline) this.newlines(); + const entry = BINARY.get(this.peek().kind); + if (!entry || entry[0] < minimum) break; + const operator = this.take(); + const right = this.expression(entry[0] + 1, multiline); + left = this.node(operator, { kind: 'binary', op: entry[1], left, right }, [left, right]); + } + this.depth--; + return left; + } + block(depth = 0) { + if (depth >= LIMITS.depth) fail(this.peek(), 'Handler nesting exceeds 64'); + this.newlines(); this.expect('{'); this.separators(); + const statements = []; + while (!this.is('}')) { + const token = this.expect('name', 'assignment, if, or closing brace'); + if (token.value === 'if' && !this.is('=')) { + const condition = this.expression(); + const body = this.block(depth + 1); + statements.push({ kind: 'if', token, condition, body }); + } else { + const name = this.reference(token); + this.expect('='); + statements.push({ kind: 'assign', token, name, expression: this.expression() }); + } + this.end(); + } + this.take(); + return statements; + } + parse() { + const declarations = []; + this.separators(); + while (!this.is('eof')) { + const token = this.expect('name', 'declaration'); + const kind = token.value; + if (kind === 'state') { + const name = this.expect('name', 'state name'); this.expect('='); + declarations.push({ kind, token, name, initial: this.initial() }); + } else if (kind === 'record') { + const name = this.expect('name', 'record type'); this.newlines(); this.expect('{'); this.separators(); + const fields = []; + while (!this.is('}')) { + const field = this.expect('name', 'field name'); this.expect('='); + fields.push({name:field, initial:this.initial()}); this.end(); + } + this.take(); declarations.push({kind,token,name,fields}); + } else if (kind === 'item') { + const name = this.expect('name', 'item name'); this.expect('='); + const type = this.expect('name', 'record type'); this.expect('('); this.newlines(); + const fields = []; + while (!this.is(')')) { + const field = this.expect('name', 'field name'); this.expect('='); + fields.push({name:field, initial:this.initial()}); this.newlines(); + if (!this.is(',')) break; + this.take(); this.newlines(); + } + this.expect(')'); declarations.push({kind,token,name,type,fields}); + } else if (kind === 'rule') { + const name = this.expect('name', 'rule name'); this.expect('('); + const parameter = this.expect('name', 'record parameter'); this.expect(':'); + const type = this.expect('name', 'record type'); this.expect(')'); + declarations.push({kind,token,name,parameter,type,body:this.block()}); + } else if (kind === 'input') { + const name = this.expect('name', 'input name'); this.expect('='); + const key = this.expect('name', 'keyboard code or pointer(view)'); + if (key.value === 'value') { + declarations.push({kind,token,name,valueInput:true}); this.end(); continue; + } + if (key.value === 'pointer') { + if (this.is('{')) { + this.take(); this.separators(); const routes = []; + while (!this.is('}')) { + const target = this.expect('name', 'view ID'); this.expect(':'); + const item = this.expect('name', 'item name'); routes.push({target,item}); this.end(); + } + this.take(); declarations.push({kind,token,name,routes}); this.end(); continue; + } + this.expect('('); + const target = this.expect('name', 'view ID'); this.expect(')'); + declarations.push({ kind, token, name, target }); + this.end(); continue; + } + if (!/^(?:Space|Enter|Arrow(?:Left|Right|Up|Down)|Key[A-Z]|Digit[0-9])$/.test(key.value)) fail(key, 'Unsupported keyboard code'); + declarations.push({ kind, token, name, key }); + } else if (Object.hasOwn(VIEW_FIELDS, kind)) { + const name = this.expect('name', 'view ID'); this.expect('('); + const expressions = []; + for (let i = 0; i < VIEW_FIELDS[kind].length; i++) { + if (i) { this.newlines(); this.expect(','); } + expressions.push(this.expression(0, true)); + } + this.newlines(); + let color, prefix = ''; + if (kind !== 'text') { + this.expect(','); this.newlines(); + const value = this.expect('string', 'literal hex color'); + if (!/^#[0-9a-fA-F]{6}$/.test(value.value)) fail(value, 'Color must use six hexadecimal digits, such as "#356548"'); + color = value.value; + } else if (this.is(',')) { this.take(); this.newlines(); prefix = this.expect('string', 'literal text prefix').value; } + this.newlines(); this.expect(')'); + declarations.push({ kind: 'view', type: kind, token, name, expressions, color, prefix }); + } else if (kind === 'on') { + const event = this.expect('name', 'input name or frame'); + let edge; + if (event.value !== 'frame') { + this.expect('.'); edge = this.expect('name', 'down, up, move or cancel'); + if (!['down', 'up', 'move', 'cancel', 'change'].includes(edge.value)) fail(edge, 'Expected down or up, move, cancel or change'); + } + if (this.is('name', 'use')) { + this.take(); const rule = this.expect('name', 'rule name'); this.expect('('); + const item = this.expect('name', 'item name or input.target'); + const argument = this.reference(item); this.expect(')'); + declarations.push({kind:'handler',token,event,edge,rule,item,argument}); + } else declarations.push({ kind: 'handler', token, event, edge, body: this.block() }); + } else fail(token, `Unknown declaration ${kind}`); + this.end(); + } + return declarations; + } +} + +export function compile(source) { + if (typeof source !== 'string') fail({ line: 1, column: 1 }, 'Source must be a string'); + const sourceBytes = new TextEncoder().encode(source).length; + if (sourceBytes > LIMITS.sourceBytes) fail({ line: 1, column: 1 }, 'Source exceeds 32768 UTF-8 bytes'); + const declarations = new Parser(lex(source)).parse(); + const program = { states: [], inputs: [], instructions: [], handlers: [], views: [], pointerTargets: [], metrics: {} }; + const states = new Map(), inputs = new Map(), views = new Set(), keys = new Set(), targets = new Set(); + const records = new Map(), items = new Map(), rules = new Map(); + const scalarNames = new Set(declarations.filter(d => d.kind === 'state').map(d => d.name.value)); + for (const declaration of declarations) { + if (!['record','rule'].includes(declaration.kind)) continue; + const {name,fields,kind} = declaration, symbols = kind === 'record' ? records : rules; + if (RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`); + if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`); + if (kind === 'record') { + if (!fields.length || fields.length > 8) fail(name, 'A record needs 1..8 fields'); + const used = new Set(); + for (const field of fields) { + if (used.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`); + if (RESERVED.has(field.name.value)) fail(field.name, `Reserved name ${field.name.value}`); + used.add(field.name.value); + } + } + symbols.set(name.value, declaration); + } + let handlerCount = 0; + for (const declaration of declarations) { + const { kind, name, token } = declaration; + if (name && RESERVED.has(name.value)) fail(name, `Reserved name ${name.value}`); + if (kind === 'record' || kind === 'rule') continue; + if (kind === 'item') { + if (items.has(name.value) || scalarNames.has(name.value)) fail(name, `Duplicate state/item ${name.value}`); + const record = records.get(declaration.type.value); + if (!record) fail(declaration.type, `Unknown record ${declaration.type.value}`); + const overrides = new Map(); + for (const field of declaration.fields) { + if (!record.fields.some(f => f.name.value === field.name.value)) fail(field.name, `Unknown field ${field.name.value}`); + if (overrides.has(field.name.value)) fail(field.name, `Duplicate field ${field.name.value}`); + overrides.set(field.name.value, field.initial); + } + const base = states.size; + for (const field of record.fields) { + if (states.size >= LIMITS.states) fail(name, 'At most 32 states are allowed, including item fields'); + const qualified = `${name.value}.${field.name.value}`, index = states.size; + states.set(qualified,index); + program.states.push({name:qualified,initial:overrides.has(field.name.value) ? overrides.get(field.name.value) : field.initial,line:token.line}); + } + items.set(name.value,{type:declaration.type.value,base,count:record.fields.length}); + } else if (kind === 'state' || kind === 'input') { + const symbols = kind === 'state' ? states : inputs; + if (symbols.has(name.value)) fail(name, `Duplicate ${kind} ${name.value}`); + if (symbols.size >= LIMITS[`${kind}s`]) fail(name, `At most ${LIMITS[`${kind}s`]} ${kind}s are allowed`); + symbols.set(name.value, symbols.size); + if (kind === 'state') program.states.push({ name: name.value, initial: declaration.initial, line: token.line }); + else if (declaration.valueInput) { + program.inputs.push({name:name.value,kind:'value',line:token.line}); + } else if (declaration.routes) { + if (!declaration.routes.length) fail(name, 'A pointer group needs at least one target'); + program.inputs.push({name:name.value, routes:[], line:token.line}); + } else if (declaration.target) { + const target = declaration.target; + if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`); + targets.add(target.value); + program.inputs.push({ name: name.value, target: target.value, line: token.line }); + } else { + if (keys.has(declaration.key.value)) fail(declaration.key, `Duplicate keyboard code ${declaration.key.value}`); + keys.add(declaration.key.value); + program.inputs.push({ name: name.value, key: declaration.key.value, line: token.line }); + } + } else if (kind === 'view') { + if (views.has(name.value)) fail(name, `Duplicate view ID ${name.value}`); + if (views.size >= LIMITS.views) fail(name, 'At most 24 views are allowed'); + views.add(name.value); + } else if (++handlerCount > LIMITS.handlers) fail(token, 'At most 64 handlers are allowed'); + } + for (const declaration of declarations) { + if (declaration.target && !views.has(declaration.target.value)) + fail(declaration.target, `Unknown pointer target ${declaration.target.value}`); + } + const routedItems = new Set(); + for (const declaration of declarations) { + if (!declaration.routes) continue; + const input = inputs.get(declaration.name.value), resolved = program.inputs[input]; + let type; + for (const {target,item:token} of declaration.routes) { + if (!views.has(target.value)) fail(target, `Unknown pointer target ${target.value}`); + if (targets.has(target.value)) fail(target, `Duplicate pointer target ${target.value}`); + const item = items.get(token.value); + if (!item) fail(token, `Unknown item ${token.value}`); + if (type && type !== item.type) fail(token, 'Pointer group targets must share one record type'); + if (routedItems.has(token.value)) fail(token, `Item ${token.value} already has a pointer route`); + targets.add(target.value); routedItems.add(token.value); type = item.type; + const route = program.pointerTargets.length; + if (route >= 24) fail(target, 'At most 24 pointer targets are allowed'); + program.pointerTargets.push({input,base:item.base,count:item.count}); + resolved.routes.push({target:target.value, item:token.value, route}); + } + resolved.type = type; + } + for (const rule of rules.values()) { + if (!records.has(rule.type.value)) fail(rule.type, `Unknown record ${rule.type.value}`); + if (RESERVED.has(rule.parameter.value)) fail(rule.parameter, `Reserved name ${rule.parameter.value}`); + } + let pointHandler = false, valueHandler = false; + let parameters = null; + let parameterPrefix = null; + let stack = 0, maxStack = 0; + const emit = (op, token, arg = 0, value = 0, delta = 0) => { + if (program.instructions.length >= LIMITS.instructions) fail(token, 'At most 512 instructions are allowed'); + stack += delta; + if (stack < 0 || stack > LIMITS.stack) fail(token, 'Expression stack exceeds 64'); + maxStack = Math.max(maxStack, stack); + program.instructions.push({ op, arg, value, line: token.line }); + }; + const expression = (node, binding = false) => { + const { token } = node; + if (node.kind === 'constant') emit(0, token, 0, node.value, 1); + else if (node.kind === 'reference') { + if (node.name === 'event_value') { + if (binding || !valueHandler) fail(token, 'event_value is only available in value change handlers'); + emit(26, token, 0, 0, 1); + } else if (node.name === 'event_x' || node.name === 'event_y') { + if (binding || !pointHandler) fail(token, 'event_x and event_y are only available in pointer handlers'); + emit(node.name === 'event_x' ? 22 : 23, token, 0, 0, 1); + } else if (node.name === 'dt') { + if (binding) fail(token, 'dt is only available in handlers'); + emit(2, token, 0, 0, 1); + } else { + if (parameterPrefix && node.name.startsWith(parameterPrefix) && !parameters.has(node.name)) + fail(token, `Unknown parameter field ${node.name}`); + if (parameters?.has(node.name)) { + emit(24, token, parameters.get(node.name), 0, 1); + return; + } + if (!states.has(node.name)) fail(token, `Unknown state ${node.name}`); + emit(1, token, states.get(node.name), 0, 1); + } + } else if (node.kind === 'unary') { expression(node.child, binding); emit(node.op, token); } + else if (node.kind === 'binary') { + expression(node.left, binding); expression(node.right, binding); emit(node.op, token, 0, 0, -1); + } else { + for (const arg of node.args) expression(arg, binding); + emit(node.op, token, 0, 0, 1 - node.args.length); + } + }; + const statements = body => { + for (const statement of body) { + if (statement.kind === 'assign') { + if (parameterPrefix && statement.name.startsWith(parameterPrefix) && !parameters.has(statement.name)) + fail(statement.token, `Unknown parameter field ${statement.name}`); + const parameter = parameters?.get(statement.name); + if (parameter === undefined && !states.has(statement.name)) fail(statement.token, `Unknown state ${statement.name}`); + expression(statement.expression); + emit(parameter === undefined ? 20 : 25, statement.token, parameter === undefined ? states.get(statement.name) : parameter, 0, -1); + } else { + expression(statement.condition); + const branch = program.instructions.length; + emit(21, statement.token, 0, 0, -1); + statements(statement.body); + program.instructions[branch].arg = program.instructions.length; + } + } + }; + const bodies = new Map(); + function ruleBody(rule) { + if (bodies.has(rule.name.value)) return bodies.get(rule.name.value); + parameterPrefix = `${rule.parameter.value}.`; + parameters = new Map(records.get(rule.type.value).fields.map((field,index) => [`${rule.parameter.value}.${field.name.value}`,index])); + // A shared body may read coordinates; every binding is checked below. + pointHandler = true; valueHandler = true; stack = 0; + const start = program.instructions.length; + statements(rule.body); + const length = program.instructions.length - start; + const point = program.instructions.slice(start).some(op => op.op === 22 || op.op === 23); + parameters = null; parameterPrefix = null; + const value = program.instructions.slice(start).some(op => op.op === 26); + const body = {start,length,point,value}; bodies.set(rule.name.value,body); return body; + } + for (const declaration of declarations) { + if (declaration.kind === 'handler') { + let event = 0; + pointHandler = false; valueHandler = false; + if (declaration.event.value !== 'frame') { + if (!inputs.has(declaration.event.value)) fail(declaration.event, `Unknown input ${declaration.event.value}`); + const index = inputs.get(declaration.event.value), edge = declaration.edge.value; + pointHandler = Boolean(program.inputs[index].target || program.inputs[index].routes); + valueHandler = program.inputs[index].kind === 'value'; + if (valueHandler !== (edge === 'change')) fail(declaration.edge, 'Value inputs require change; change requires a value input'); + if (!pointHandler && (edge === 'move' || edge === 'cancel')) fail(declaration.edge, 'move and cancel require a pointer input'); + event = edge === 'change' ? 33 + index : edge === 'move' ? 17 + index : edge === 'cancel' ? 25 + index : 1 + index * 2 + (edge === 'up' ? 1 : 0); + } + if (declaration.rule) { + const rule = rules.get(declaration.rule.value); + const routed = declaration.argument.includes('.'); + const input = program.inputs[inputs.get(declaration.event.value)]; + if (routed && (!input?.routes || declaration.argument !== `${declaration.event.value}.target`)) + fail(declaration.item, 'Use the current pointer group target'); + const item = routed ? {type:input.type, count:records.get(input.type).fields.length} : items.get(declaration.item.value); + if (!rule) fail(declaration.rule, `Unknown rule ${declaration.rule.value}`); + if (!item) fail(declaration.item, `Unknown item ${declaration.item.value}`); + if (item.type !== rule.type.value) fail(declaration.item, `Rule ${rule.name.value} requires ${rule.type.value}`); + const pointer = pointHandler, value = valueHandler; + const body = ruleBody(rule); + if (body.value && !value) fail(declaration.rule, 'Value-reading rules require a value input'); + if (body.point && !pointer) fail(declaration.rule, 'Coordinate-reading rules require a pointer input'); + program.handlers.push({event,start:body.start,length:body.length,line:declaration.token.line,parameterBase:routed ? 255 : item.base,parameterCount:item.count}); + } else { + const start = program.instructions.length; + stack = 0; statements(declaration.body); + program.handlers.push({ event, start, length: program.instructions.length - start, line: declaration.token.line }); + } + } else if (declaration.kind === 'view') { + const view = { id: declaration.name.value, type: declaration.type, line: declaration.token.line, bindings: {} }; + if (view.type === 'text') view.prefix = declaration.prefix; + else view.color = declaration.color; + declaration.expressions.forEach((node, index) => { + const start = program.instructions.length; + stack = 0; expression(node, true); + view.bindings[VIEW_FIELDS[view.type][index]] = { start, length: program.instructions.length - start }; + }); + program.views.push(view); + } + } + // Unused definitions still receive name/type checking, but reserve no VM body. + for (const rule of rules.values()) { + if (bodies.has(rule.name.value)) continue; + const instructions = program.instructions, previousMax = maxStack; + program.instructions = []; + ruleBody(rule); + program.instructions = instructions; maxStack = previousMax; + } + program.metrics = { sourceBytes, sourceLines: source === '' ? 0 : source.split('\n').length, + states: states.size, inputs: inputs.size, views: views.size, + handlers: program.handlers.length, instructions: program.instructions.length, maxStack }; + return program; +} + +export { LIMITS }; diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/core.wasm b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/core.wasm Binary files differnew file mode 100644 index 0000000..79c50a8 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/core.wasm diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/project.mjs b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/project.mjs new file mode 100644 index 0000000..861e0fc --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/project.mjs @@ -0,0 +1,47 @@ +import { compile } from './compiler.mjs'; + +// Two escaped source strings plus up to 32 generated item.field names. A long +// schema field can appear in every item without repeating in the source text. +export const MAX_PROJECT_BYTES = (2 * 6 + 32) * 32768 + 8192; +const encoder = new TextEncoder(); +function sourceText(value, label) { + if (typeof value !== 'string' || encoder.encode(value).length > 32768) + throw new Error(`${label}: UTF-8 32 KiB 이하의 코드가 필요합니다.`); + return value; +} + +// No runtime mutation. Validate every saved value before opening core staging. +export function validateDocument(document) { + if (!document || typeof document !== 'object' || Array.isArray(document) || + document.format !== 'pure-workbench' || document.version !== 2) + throw new Error('배치 파일 형식이 맞지 않습니다. pure-workbench version 2가 필요합니다.'); + const source = sourceText(document.source, '적용된 코드'); + const draft = sourceText(Object.hasOwn(document, 'draft') ? document.draft : source, '편집 중인 코드'); + const program = compile(source); + if (!Array.isArray(document.states) || document.states.length !== program.states.length) + throw new Error('저장된 상태 목록이 적용된 코드와 일치하지 않습니다.'); + const values = new Map(); + for (const entry of document.states) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry) || typeof entry.name !== 'string' || values.has(entry.name)) + throw new Error('상태 이름이 잘못됐거나 중복됐습니다.'); + const value = entry.value === '-0' ? -0 : entry.value; + if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error('상태 값은 유한한 숫자여야 합니다.'); + values.set(entry.name, value); + } + for (const state of program.states) + if (!values.has(state.name)) throw new Error(`저장된 상태가 없습니다: ${state.name}`); + return { source, draft, program, values: program.states.map(state => values.get(state.name)) }; +} + +export function parseProject(text) { + if (typeof text !== 'string' || encoder.encode(text).length > MAX_PROJECT_BYTES) + throw new Error('작업 파일이 너무 큽니다.'); + let project; + try { project = JSON.parse(text); } catch { throw new Error('올바른 JSON 파일이 아닙니다.'); } + if (!project || typeof project !== 'object' || Array.isArray(project) || project.format !== 'pure-workbench') + throw new Error('Pure 작업물 형식이 아닙니다.'); + if (project.version === 1) sourceText(project.source, '코드'); + else if (project.version === 2) validateDocument(project); + else throw new Error('지원하지 않는 작업 파일 버전입니다.'); + return project; +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/runtime.mjs b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/runtime.mjs new file mode 100644 index 0000000..d8d6618 --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/sources/prototypes/workbench/web/runtime.mjs @@ -0,0 +1,350 @@ +import { compile } from './compiler.mjs'; +import { validateDocument } from './project.mjs'; + +const messages = [ + '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.', + '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.', + '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.', + '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.', + '정의되지 않은 입력 사건입니다.', +]; +const sourceLimit = 32768; + +function problem(message, line = null, column = null) { + return Object.assign(new Error(message), { line, column }); +} + +function freezeProgram(value) { + if (value && typeof value === 'object') { + for (const child of Object.values(value)) freezeProgram(child); + Object.freeze(value); + } + return value; +} + +export async function createRuntime(wasmBytes, evaluationOptions = {}) { + if (wasmBytes === undefined) { + const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' }); + if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`); + wasmBytes = await response.arrayBuffer(); + } + const module = await WebAssembly.compile(wasmBytes); + if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.'); + const { exports: core } = await WebAssembly.instantiate(module, {}); + if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function' || typeof core.dispatch_point !== 'function' || typeof core.put_restored_value !== 'function' || typeof core.commit_restored_program !== 'function' || typeof core.bind_handler !== 'function' || typeof core.put_pointer_target !== 'function' || typeof core.bind_target_handler !== 'function' || typeof core.dispatch_target !== 'function' || typeof core.dispatch_value !== 'function') + throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.'); + core.reset_all(); + let program = null; + let appliedSource = ''; + let lastEvent = null; + // Keep a failed event visible through its release/focus-loss cleanup. + // The active program can clear this diagnostic by apply or explicit reset. + let lastFailure = null; + const held = new Map(); + const points = new Map(); + let presenting = false; + let bindings = []; + let pointRoutes = new Map(); + // One bounded publication buffer, never exposed to callers. No state mirror. + let displayValues = null, displayValid = null; + function available() { + if (presenting) throw new Error('출력 전달 중에는 실행기를 다시 호출할 수 없습니다.'); + } + let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 }; + + function checked(code, line = null) { + if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line); + } + function inputIndex(name) { + const index = program?.inputs.findIndex((input) => input.name === name) ?? -1; + if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`); + return index; + } + function dispatch(event, name, phase, source, point = null, value = undefined) { + const status = value !== undefined ? core.dispatch_value(event, value) : point ? (point.route === undefined ? core.dispatch_point(event, point.x, point.y) + : core.dispatch_target(event, point.route, point.x, point.y)) : core.dispatch(event, event === 0 ? 1 / 60 : 0); + const pc = core.info(2) >>> 0; + lastEvent = { name, phase, source, status, + line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 }; + if (point) { lastEvent.x = point.x; lastEvent.y = point.y; if (point.target) lastEvent.target = point.target; } + if (value !== undefined) lastEvent.value = value; + if (status) lastFailure = { ...lastEvent }; + return status; + } + + function evaluateDisplay() { + core.begin_evaluation(evaluationPolicy.workLimit); + if (!bindings.length) return null; + displayValues ??= new Float64Array(96); + displayValid ??= new Uint8Array(24); + let error = null, slot = 0; + for (let i = 0; i < bindings.length; i++) { + const view = program.views[i]; + displayValid[i] = 1; + for (const [name, binding] of bindings[i]) { + const value = core.eval_bounded(binding.start, binding.length); + if (core.evaluation_info(0) === 10) { + displayValid.fill(0); + return { code: 101, line: view.line, + message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' }; + } + if (!Number.isFinite(value) || ((name === 'width' || name === 'height' || name === 'radius') && value < 0)) { + error ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` }; + displayValid[i] = 0; + } + displayValues[slot++] = value; + } + } + return error; + } + + function status(viewError) { + const executionError = lastFailure?.status || 0; + return { + tick: core.info(0) >>> 0, revision: core.info(4) >>> 0, + displayError: viewError ? { code: viewError.code || 100, ...viewError } : null, + lastError: executionError || (viewError ? viewError.code || 100 : 0), + errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '', + errorLine: executionError ? lastFailure.line : viewError?.line ?? null, + lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0, + held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null, + lastFailure: lastFailure ? { ...lastFailure } : null, + memory: { + linearBytes: core.memory.buffer.byteLength, + reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1), + instructionBytes: core.layout(2), handlerBytes: core.layout(3), + activeIRBytes: core.layout(4), activeStateBytes: core.layout(5), + stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7), + pointerTargetBytes: core.layout(8), pointerTargets: core.layout(9), + }, + }; + } + + function load(next, source, restored = null) { + const oldIds = new Map(restored ? [] : program?.states.map((state, index) => [state.name, index]) ?? []); + checked(core.begin_program(next.states.length, next.inputs.length)); + try { + for (let index = 0; index < next.states.length; index++) { + const state = next.states[index]; + checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line); + if (restored) checked(core.put_restored_value(index, restored[index]), state.line); + } + for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line); + for (const target of next.pointerTargets) checked(core.put_pointer_target(target.input, target.base, target.count)); + for (const [index, handler] of next.handlers.entries()) { + checked(core.put_handler(handler.event, handler.start, handler.length), handler.line); + if (handler.parameterCount) + checked(handler.parameterBase === 255 ? core.bind_target_handler(index, handler.parameterCount) + : core.bind_handler(index, handler.parameterBase, handler.parameterCount), handler.line); + } + checked(restored ? core.commit_restored_program() : core.commit_program()); + } catch (error) { + core.abort_program(); + throw error; + } + pointRoutes = new Map(next.inputs.flatMap(input => (input.routes ?? []).map(route => [route.target, route]))); + program = next; + bindings = next.views.map(view => Object.entries(view.bindings)); + appliedSource = source; + lastEvent = null; + lastFailure = null; + } + + const api = { + get currentProgram() { return program; }, + configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries, + historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) { + available(); + for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32], + ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) { + if (!Number.isInteger(value) || value < 0 || value > maximum) + throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`); + } + checked(core.configure_evaluation(cacheEntries, historyEntries)); + evaluationPolicy = { cacheEntries, historyEntries, workLimit }; + return { ...evaluationPolicy }; + }, + forgetEvaluation(part = 'all') { + available(); + const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined; + if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.'); + core.forget_evaluation(flags); + }, + evaluationStats() { + return { ...evaluationPolicy, work: core.evaluation_info(1), + instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3), + reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7), + sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)), + workspaceTagBytes: core.evaluation_info(10) }; + }, + apply(source) { + available(); + if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) { + throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.'); + } + if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.'); + const next = freezeProgram(compile(source)); + load(next, source); + return next; + }, + restoreDocument(document) { + available(); + if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 복원하세요.'); + const next = validateDocument(document); + load(freezeProgram(next.program), next.source, next.values); + return { source: next.source, draft: next.draft }; + }, + exportDocument(draft = appliedSource) { + available(); + if (!program) throw problem('먼저 코드를 적용하세요.'); + if (held.size) throw problem('조작을 끝낸 뒤 배치를 저장하세요.'); + if (lastFailure) throw problem('실행 오류를 해결한 뒤 배치를 저장하세요.'); + if (typeof draft !== 'string' || new TextEncoder().encode(draft).length > sourceLimit) + throw problem('편집 중인 코드는 UTF-8 32 KiB 이하여야 합니다.'); + return { format: 'pure-workbench', version: 2, source: appliedSource, draft, + states: program.states.map((state, i) => { + const value = core.get_state(i); + return { name: state.name, value: Object.is(value, -0) ? '-0' : value }; + }) }; + }, + reset() { + available(); + held.clear(); + points.clear(); + core.reset_state(); + lastEvent = null; + lastFailure = null; + }, + frame(count = 1) { + available(); + if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.'); + if (!program) return 0; + for (let i = 0; i < count; i++) { + const code = dispatch(0, 'frame', 'frame', 'clock'); + if (code !== 0) return code; + } + return 0; + }, + down(name, source = 'external') { + available(); + const index = inputIndex(name); + if (program.inputs[index].kind === 'value') throw new Error('숫자 입력은 change(name, value)를 사용하세요.'); + if (program.inputs[index].target || program.inputs[index].routes) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.'); + const sources = held.get(name) || new Set(); + if (sources.has(source)) return 0; + const wasDown = sources.size > 0; + sources.add(source); + held.set(name, sources); + return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source); + }, + up(name, source = 'external') { + available(); + const index = inputIndex(name); + if (program.inputs[index].kind === 'value') throw new Error('숫자 입력은 change(name, value)를 사용하세요.'); + if (program.inputs[index].target || program.inputs[index].routes) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.'); + const sources = held.get(name); + if (!sources?.delete(source)) return 0; + if (sources.size) return 0; + held.delete(name); + return dispatch(2 + index * 2, name, 'up', source); + }, + change(name, value, source = 'external') { + available(); + const index = inputIndex(name); + if (program.inputs[index].kind !== 'value') throw new Error('숫자 값에 연결된 입력이 아닙니다.'); + if (!Number.isFinite(value)) throw new Error('입력 값은 유한한 숫자여야 합니다.'); + return dispatch(33 + index, name, 'change', source, null, value); + }, + point(name, phase, x, y, source = 'external', target = undefined) { + available(); + const index = inputIndex(name); + const input = program.inputs[index]; + if (!input.target && !input.routes) throw new Error('포인터에 연결된 입력이 아닙니다.'); + const route = input.routes ? pointRoutes.get(target) : undefined; + if (input.routes && (!route || program.pointerTargets[route.route].input !== index)) throw new Error('포인터 그룹에 연결된 도형 이름이 필요합니다.'); + if (!input.routes && target !== undefined) throw new Error('단일 포인터 입력에는 대상 인자를 지정하지 않습니다.'); + const owner = route ? `${name}:${target}` : name; + if (!['down', 'move', 'up', 'cancel'].includes(phase)) throw new Error('포인터 단계는 down, move, up, cancel 중 하나여야 합니다.'); + if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('포인터 좌표는 유한한 숫자여야 합니다.'); + if (typeof source !== 'string' || source.length === 0) throw new Error('포인터 출처 이름이 필요합니다.'); + let point = points.get(owner); + if (phase === 'down') { + if (point?.source === source) return 0; + if (point) throw new Error('이 입력은 다른 포인터가 사용 중입니다.'); + point = {name, source, x, y, target, route:route?.route}; + points.set(owner, point); + held.set(owner, new Set([source])); + } else { + if (!point || point.source !== source) return 0; + point.x = x; point.y = y; + if (phase === 'up' || phase === 'cancel') { points.delete(owner); held.delete(owner); } + } + const event = phase === 'move' ? 17 + index : phase === 'cancel' ? 25 + index : 1 + index * 2 + (phase === 'up' ? 1 : 0); + return dispatch(event, name, phase, source, point); + }, + releaseAll() { + available(); + let firstError = 0; + for (const point of [...points.values()]) { + const status = api.point(point.name, 'cancel', point.x, point.y, point.source, point.target); + if (!firstError) firstError = status; + } + for (const [name, sources] of [...held]) { + for (const source of [...sources]) { + const status = api.up(name, source); + if (!firstError) firstError = status; + } + } + return firstError; + }, + exportProject() { + return { format: 'pure-workbench', version: 1, source: appliedSource }; + }, + // Synchronous trusted output adapter. Callbacks run only after the whole + // display has been evaluated. Runtime mutation/reentrant reads are refused. + // Exceptions from an adapter propagate; the next call republishes all values. + present(target) { + available(); + presenting = true; + try { + const { begin, state, view, value, end } = target; + for (const callback of [begin, state, view, value, end]) + if (typeof callback !== 'function') throw new TypeError('출력 어댑터 함수가 필요합니다.'); + const error = evaluateDisplay(); + begin.call(target, program); + for (let i = 0; i < (program?.states.length ?? 0); i++) state.call(target, i, core.get_state(i)); + let slot = 0, visible = 0; + for (let i = 0; i < bindings.length; i++) { + const valid = Boolean(displayValid[i]); + view.call(target, i, valid); + for (const [name] of bindings[i]) { + if (valid) value.call(target, i, name, displayValues[slot]); + slot++; + } + if (valid) visible++; + } + end.call(target, visible); + return status(error); + } finally { presenting = false; } + }, + snapshot() { + available(); + const error = evaluateDisplay(); + const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)])); + const views = []; + let slot = 0; + for (let i = 0; i < bindings.length; i++) { + const view = program.views[i]; + const rendered = displayValid[i] ? { id: view.id, type: view.type, color: view.color, prefix: view.prefix } : null; + for (const [name] of bindings[i]) { + if (rendered) rendered[name] = displayValues[slot]; + slot++; + } + if (rendered) views.push(rendered); + } + return { states, views, ...status(error) }; + }, + }; + api.configureEvaluation(evaluationOptions); + return Object.freeze(api); +} diff --git a/prototypes/connections/results/2026-09-23-native-connections/verification.json b/prototypes/connections/results/2026-09-23-native-connections/verification.json new file mode 100644 index 0000000..3a8b2cc --- /dev/null +++ b/prototypes/connections/results/2026-09-23-native-connections/verification.json @@ -0,0 +1,29 @@ +{ + "scope": "Correctness, lifetimes, admission bounds; no throughput, latency, or RSS comparison", + "compiler": "cc (Debian 14.2.0-19) 14.2.0", + "native_command": "make check EVIDENCE=results/2026-09-23-native-connections/native.json", + "sanitized_command": "make check BUILD=build/sanitized CFLAGS='-std=c17 -O1 -g -Wall -Wextra -Wpedantic -Werror -fsanitize=address,undefined -fno-omit-frame-pointer' EVIDENCE=results/2026-09-23-native-connections/sanitized.json", + "core_checks": { + "native": "passed", + "asan_ubsan": "passed", + "leak_detection_disabled": false + }, + "integration_scenarios_each": 15, + "cli_regression": { + "command": "node --test cli.test.mjs", + "cwd": "prototypes/workbench", + "passed": 8, + "failed": 0, + "test_sha256": "7e4688824e26dc7e56396f9d4d62726de0ccb1490e1a388bb64690ae2aae52f4" + }, + "source_hashes_equal": true, + "source_snapshots": "sources/", + "final_binaries_match_records": true, + "limitations": [ + "SQLite shared library was not rebuilt with sanitizers", + "Linux AArch64 only; no macOS execution", + "Bounded payload retention excludes SQLite internals, OS socket buffers and client allocations", + "SQLite operations remain synchronous; absolute deadlines are enforced between those operations", + "Native connection path is fixed in C; Pure-authored external effects are not implemented" + ] +} |
