SxExpr aser wire format fix + Playwright test infrastructure + blob protocol

Aser serialization: aser-call/fragment now return SxExpr instead of String.
serialize/inspect passes SxExpr through unquoted, preventing the double-
escaping (\" → \\\" ) that broke client-side parsing when aser wire format
was output via raw! into <script> tags. Added make-sx-expr + sx-expr-source
primitives to OCaml and JS hosts.

Binary blob protocol: eval, aser, aser-slot, and sx-page-full now send SX
source as length-prefixed blobs instead of escaped strings. Eliminates pipe
desync from concurrent requests and removes all string-escape round-trips
between Python and OCaml.

Bridge safety: re-entrancy guard (_in_io_handler) raises immediately if an
IO handler tries to call the bridge, preventing silent deadlocks.

Fetch error logging: orchestration.sx error callback now logs method + URL
via log-warn. Platform catches (fetchAndRestore, fetchPreload, bindBoostForm)
also log errors instead of silently swallowing them.

Transpiler fixes: makeEnv, scopePeek, scopeEmit, makeSxExpr added as
platform function definitions + transpiler mappings — were referenced in
transpiled code but never defined as JS functions.

Playwright test infrastructure:
- nav() captures JS errors and fails fast with the actual error message
- Checks for [object Object] rendering artifacts
- New tests: delete-row interaction, full page refresh, back button,
  direct load with fresh context, code block content verification
- Default base URL changed to localhost:8013 (standalone dev server)
- docker-compose.dev-sx.yml: port 8013 exposed for local testing
- test-sx-build.sh: build + unit tests + Playwright smoke tests

Geography content: index page component written (sx/sx/geography/index.sx)
describing OCaml evaluator, wire formats, rendering pipeline, and topic
links. Wiring blocked by aser-expand-component children passing issue.

Tests: 1080/1080 JS, 952/952 OCaml, 66/66 Playwright

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 22:17:43 +00:00
parent 6d73edf297
commit df461beec2
17 changed files with 684 additions and 82 deletions

View File

@@ -41,6 +41,7 @@ class OcamlBridge:
self._binary = binary or os.environ.get("SX_OCAML_BIN") or _DEFAULT_BIN
self._proc: asyncio.subprocess.Process | None = None
self._lock = asyncio.Lock()
self._in_io_handler = False # re-entrancy guard
self._started = False
self._components_loaded = False
self._helpers_injected = False
@@ -123,7 +124,8 @@ class OcamlBridge:
"""
await self._ensure_components()
async with self._lock:
await self._send(f'(eval "{_escape(source)}")')
await self._send('(eval-blob)')
await self._send_blob(source)
return await self._read_until_ok(ctx)
async def render(
@@ -141,7 +143,8 @@ class OcamlBridge:
"""Evaluate SX and return SX wire format, handling io-requests."""
await self._ensure_components()
async with self._lock:
await self._send(f'(aser "{_escape(source)}")')
await self._send('(aser-blob)')
await self._send_blob(source)
return await self._read_until_ok(ctx)
async def aser_slot(self, source: str, ctx: dict[str, Any] | None = None) -> str:
@@ -156,7 +159,8 @@ class OcamlBridge:
# a separate lock acquisition could let another coroutine
# interleave commands between injection and aser-slot.
await self._inject_helpers_locked()
await self._send(f'(aser-slot "{_escape(source)}")')
await self._send('(aser-slot-blob)')
await self._send_blob(source)
return await self._read_until_ok(ctx)
_shell_statics_injected: bool = False
@@ -227,7 +231,10 @@ class OcamlBridge:
static_keys = {"component_hash", "sx_css_classes", "asset_url",
"sx_js_hash", "body_js_hash",
"head_scripts", "body_scripts"}
parts = [f'(sx-page-full "{_escape(page_source)}"']
# page_source is SX wire format that may contain \" escapes.
# Send via binary blob protocol to avoid double-escaping
# through the SX string parser round-trip.
parts = ['(sx-page-full-blob']
for key, val in shell_kwargs.items():
k = key.replace("_", "-")
if key in PLACEHOLDER_KEYS:
@@ -248,6 +255,8 @@ class OcamlBridge:
parts.append(")")
cmd = "".join(parts)
await self._send(cmd)
# Send page source as binary blob (avoids string-escape issues)
await self._send_blob(page_source)
html = await self._read_until_ok(ctx)
# Splice in large blobs
for token, blob in placeholders.items():
@@ -473,11 +482,30 @@ class OcamlBridge:
async def _send(self, line: str) -> None:
"""Write a line to the subprocess stdin and flush."""
if self._in_io_handler:
raise OcamlBridgeError(
f"Re-entrant bridge call from IO handler: {line[:80]}. "
f"IO handlers must not call the bridge — use Python-only code."
)
assert self._proc and self._proc.stdin
_logger.debug("SEND: %s", line[:120])
self._proc.stdin.write((line + "\n").encode())
await self._proc.stdin.drain()
async def _send_blob(self, data: str) -> None:
"""Send a length-prefixed binary blob to the subprocess.
Protocol: sends "(blob N)\\n" followed by exactly N bytes, then "\\n".
The OCaml side reads the length, then reads exactly N bytes.
This avoids string-escape round-trip issues for SX wire format.
"""
assert self._proc and self._proc.stdin
encoded = data.encode()
self._proc.stdin.write(f"(blob {len(encoded)})\n".encode())
self._proc.stdin.write(encoded)
self._proc.stdin.write(b"\n")
await self._proc.stdin.drain()
async def _readline(self) -> str:
"""Read a line from the subprocess stdout."""
assert self._proc and self._proc.stdout
@@ -574,7 +602,24 @@ class OcamlBridge:
line: str,
ctx: dict[str, Any] | None,
) -> Any:
"""Dispatch an io-request to the appropriate Python handler."""
"""Dispatch an io-request to the appropriate Python handler.
IO handlers MUST NOT call the bridge (eval/aser/render) — doing so
would deadlock since the lock is already held. The _in_io_handler
flag triggers an immediate error if this rule is violated.
"""
self._in_io_handler = True
try:
return await self._dispatch_io(line, ctx)
finally:
self._in_io_handler = False
async def _dispatch_io(
self,
line: str,
ctx: dict[str, Any] | None,
) -> Any:
"""Inner dispatch for IO requests."""
from .parser import parse_all
# Parse the io-request