Route all rendering through OCaml bridge — render_to_html no longer uses Python async_eval. Fix register_components to parse &key params and &rest children from defcomp forms. Remove all dead sx_ref.py imports. Epoch protocol (prevents pipe desync): - Every command prefixed with (epoch N), all responses tagged with epoch - Both sides discard stale-epoch messages — desync structurally impossible - OCaml main loop discards stale io-responses between commands Consolidate scope primitives into sx_scope.ml: - Single source of truth for scope-push!/pop!/peek, collect!/collected, emit!/emitted, context, and 12 other scope operations - Removes duplicate registrations from sx_server.ml (including bugs where scope-emit! and clear-collected! were registered twice with different impls) - Bind scope prims into env so JIT VM finds them via OP_GLOBAL_GET JIT VM fixes: - Trampoline thunks before passing args to CALL_PRIM - as_list resolves thunks via _sx_trampoline_fn - len handles all value types (Bool, Number, RawHTML, SxExpr, Spread, etc.) Other fixes: - ~cssx/tw signature: (tokens) → (&key tokens) to match callers - Minimal Python evaluator in html.py for sync sx() Jinja function - Python scope primitive stubs (thread-local) for non-OCaml paths - Reader macro resolution via OcamlSync instead of sx_ref.py Tests: 1114 OCaml, 1078 JS, 35 Python regression, 6/6 Playwright SSR Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
168 lines
5.5 KiB
Python
168 lines
5.5 KiB
Python
"""
|
|
Synchronous OCaml bridge — persistent subprocess for build-time evaluation.
|
|
|
|
Used by bootstrappers (JS cli.py, OCaml bootstrap.py) that need a sync
|
|
evaluator to run transpiler.sx. For async runtime use, see ocaml_bridge.py.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
_DEFAULT_BIN = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"../../hosts/ocaml/_build/default/bin/sx_server.exe",
|
|
)
|
|
|
|
|
|
class OcamlSyncError(Exception):
|
|
"""Error from the OCaml SX kernel."""
|
|
|
|
|
|
def _sx_unescape(s: str) -> str:
|
|
"""Unescape an SX string literal (left-to-right, one pass)."""
|
|
out = []
|
|
i = 0
|
|
while i < len(s):
|
|
if s[i] == '\\' and i + 1 < len(s):
|
|
c = s[i + 1]
|
|
if c == 'n':
|
|
out.append('\n')
|
|
elif c == 'r':
|
|
out.append('\r')
|
|
elif c == 't':
|
|
out.append('\t')
|
|
elif c == '"':
|
|
out.append('"')
|
|
elif c == '\\':
|
|
out.append('\\')
|
|
else:
|
|
out.append(c)
|
|
i += 2
|
|
else:
|
|
out.append(s[i])
|
|
i += 1
|
|
return ''.join(out)
|
|
|
|
|
|
class OcamlSync:
|
|
"""Synchronous bridge to the OCaml sx_server subprocess."""
|
|
|
|
def __init__(self, binary: str | None = None):
|
|
self._binary = binary or os.environ.get("SX_OCAML_BIN") or _DEFAULT_BIN
|
|
self._proc: subprocess.Popen | None = None
|
|
self._epoch: int = 0
|
|
|
|
def _ensure(self):
|
|
if self._proc is not None and self._proc.poll() is None:
|
|
return
|
|
self._proc = subprocess.Popen(
|
|
[self._binary],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
self._epoch = 0
|
|
# Wait for (ready)
|
|
line = self._readline()
|
|
if line != "(ready)":
|
|
raise OcamlSyncError(f"Expected (ready), got: {line}")
|
|
|
|
def _send(self, command: str):
|
|
"""Send a command with epoch prefix."""
|
|
assert self._proc and self._proc.stdin
|
|
self._epoch += 1
|
|
self._proc.stdin.write(f"(epoch {self._epoch})\n".encode())
|
|
self._proc.stdin.write((command + "\n").encode())
|
|
self._proc.stdin.flush()
|
|
|
|
def _readline(self) -> str:
|
|
assert self._proc and self._proc.stdout
|
|
data = self._proc.stdout.readline()
|
|
if not data:
|
|
raise OcamlSyncError("OCaml subprocess died unexpectedly")
|
|
return data.decode().rstrip("\n")
|
|
|
|
def _strip_epoch(self, inner: str) -> str:
|
|
"""Strip leading epoch number from a response value: '42 value' → 'value'."""
|
|
if inner and inner[0].isdigit():
|
|
space = inner.find(" ")
|
|
if space > 0:
|
|
return inner[space + 1:]
|
|
return "" # epoch only, no value
|
|
return inner
|
|
|
|
def _read_response(self) -> str:
|
|
"""Read a single response. Returns the value string or raises on error.
|
|
|
|
Handles epoch-tagged responses: (ok EPOCH), (ok EPOCH value),
|
|
(ok-len EPOCH N), (error EPOCH "msg").
|
|
"""
|
|
line = self._readline()
|
|
# Length-prefixed blob: (ok-len N) or (ok-len EPOCH N)
|
|
if line.startswith("(ok-len "):
|
|
parts = line[1:-1].split() # ["ok-len", ...]
|
|
n = int(parts[-1]) # last number is always byte count
|
|
assert self._proc and self._proc.stdout
|
|
data = self._proc.stdout.read(n)
|
|
self._proc.stdout.readline() # trailing newline
|
|
value = data.decode()
|
|
# Blob is SX-serialized — strip string quotes and unescape
|
|
if value.startswith('"') and value.endswith('"'):
|
|
value = _sx_unescape(value[1:-1])
|
|
return value
|
|
if line == "(ok)" or (line.startswith("(ok ") and line[4:-1].isdigit()):
|
|
return ""
|
|
if line.startswith("(ok-raw "):
|
|
inner = self._strip_epoch(line[8:-1])
|
|
return inner
|
|
if line.startswith("(ok "):
|
|
value = self._strip_epoch(line[4:-1])
|
|
if value.startswith('"') and value.endswith('"'):
|
|
value = _sx_unescape(value[1:-1])
|
|
return value
|
|
if line.startswith("(error "):
|
|
msg = self._strip_epoch(line[7:-1])
|
|
if msg.startswith('"') and msg.endswith('"'):
|
|
msg = _sx_unescape(msg[1:-1])
|
|
raise OcamlSyncError(msg)
|
|
raise OcamlSyncError(f"Unexpected response: {line}")
|
|
|
|
def eval(self, source: str) -> str:
|
|
"""Evaluate SX source, return result as string."""
|
|
self._ensure()
|
|
escaped = source.replace("\\", "\\\\").replace('"', '\\"')
|
|
self._send(f'(eval "{escaped}")')
|
|
return self._read_response()
|
|
|
|
def load(self, path: str) -> str:
|
|
"""Load an .sx file into the kernel."""
|
|
self._ensure()
|
|
self._send(f'(load "{path}")')
|
|
return self._read_response()
|
|
|
|
def load_source(self, source: str) -> str:
|
|
"""Load SX source directly into the kernel."""
|
|
self._ensure()
|
|
escaped = source.replace("\\", "\\\\").replace('"', '\\"')
|
|
self._send(f'(load-source "{escaped}")')
|
|
return self._read_response()
|
|
|
|
def stop(self):
|
|
if self._proc and self._proc.poll() is None:
|
|
self._proc.terminate()
|
|
self._proc.wait(timeout=5)
|
|
self._proc = None
|
|
|
|
|
|
# Singleton
|
|
_global: OcamlSync | None = None
|
|
|
|
|
|
def get_sync_bridge() -> OcamlSync:
|
|
global _global
|
|
if _global is None:
|
|
_global = OcamlSync()
|
|
return _global
|