Files
rose-ash/shared/sx/ocaml_sync.py
giles f9f810ffd7 Complete Python eval removal: epoch protocol, scope consolidation, JIT fixes
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>
2026-03-24 16:14:40 +00:00

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