From f819fda587a9dbaa3a4d0e75f54cbc2e6f9a12fd Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 19 Mar 2026 14:56:55 +0000 Subject: [PATCH] aser_slot migration: single-pass expansion, pipe desync fix, _render_to_sx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes completing the aser_slot migration: 1. Single-pass full-page rendering: eval_sx_url builds layout+content AST and aser_slots it in ONE call — avoids double-aser where re-parsed content hits "Undefined symbol: title/deref" errors. 2. Pipe desync fix: _inject_helpers_locked runs INSIDE the aser_slot lock acquisition (not as a separate lock). Prevents interleaved commands from other coroutines between injection and aser-slot. 3. _render_to_sx uses aser_slot (not aser): layout wrappers like oob_page_sx contain re-parsed content from earlier aser_slot calls. Regular aser fails on symbols that were bound during the earlier expansion. aser_slot handles them correctly. HTMX path: aser_slot the content, then oob_page_sx wraps it. Full page path: build (~shared:layout/app-body :content wrapped_ast), aser_slot in one pass, pass directly to sx_page. New Playwright tests: test_navigate_geography_to_reactive, test_direct_load_reactive_page. Co-Authored-By: Claude Opus 4.6 (1M context) --- shared/static/scripts/sx-browser.js | 2 +- shared/sx/helpers.py | 5 ++- shared/sx/ocaml_bridge.py | 55 +++++++++++++++-------------- sx/sxc/pages/sx_router.py | 36 ++++++++++++++++--- sx/tests/test_demos.py | 26 ++++++++++++++ 5 files changed, 91 insertions(+), 33 deletions(-) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 511b10a..8116c81 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-19T12:40:33Z"; + var SX_VERSION = "2026-03-19T14:05:23Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } diff --git a/shared/sx/helpers.py b/shared/sx/helpers.py index 98ac55b..c8b89ec 100644 --- a/shared/sx/helpers.py +++ b/shared/sx/helpers.py @@ -416,7 +416,10 @@ async def _render_to_sx(__name: str, **kwargs: Any) -> str: from .parser import serialize bridge = await get_bridge() sx_text = serialize(ast) - return SxExpr(await bridge.aser(sx_text)) + # aser_slot (not aser) — layout wrappers contain re-parsed + # content from earlier aser_slot calls. Regular aser fails on + # symbols like `title` that were bound during the earlier expansion. + return SxExpr(await bridge.aser_slot(sx_text)) if os.environ.get("SX_USE_REF") == "1": from .ref.async_eval_ref import async_eval_to_sx diff --git a/shared/sx/ocaml_bridge.py b/shared/sx/ocaml_bridge.py index 781e064..b033bb3 100644 --- a/shared/sx/ocaml_bridge.py +++ b/shared/sx/ocaml_bridge.py @@ -149,45 +149,46 @@ class OcamlBridge: slots where component bodies need server-side IO evaluation. """ await self._ensure_components() - await self._ensure_helpers() async with self._lock: + # Inject helpers inside the lock to avoid pipe desync — + # a separate lock acquisition could let another coroutine + # interleave commands between injection and aser-slot. + await self._inject_helpers_locked() self._send(f'(aser-slot "{_escape(source)}")') return await self._read_until_ok(ctx) - async def _ensure_helpers(self) -> None: - """Lazily inject page helpers into the kernel as IO proxies.""" + async def _inject_helpers_locked(self) -> None: + """Inject page helpers into the kernel. MUST be called with lock held.""" if self._helpers_injected: return self._helpers_injected = True try: from .pages import get_page_helpers + import inspect helpers = get_page_helpers("sx") if not helpers: - self._helpers_injected = False # retry later + self._helpers_injected = False return - async with self._lock: - for name, fn in helpers.items(): - if callable(fn) and not name.startswith("~"): - # Determine arity from Python function signature - import inspect - try: - sig = inspect.signature(fn) - nargs = sum(1 for p in sig.parameters.values() - if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)) - except (ValueError, TypeError): - nargs = 2 - nargs = max(nargs, 1) # at least 1 arg - param_names = " ".join(chr(97 + i) for i in range(nargs)) - arg_list = " ".join(chr(97 + i) for i in range(nargs)) - sx_def = f'(define {name} (fn ({param_names}) (helper "{name}" {arg_list})))' - try: - self._send(f'(load-source "{_escape(sx_def)}")') - await self._read_until_ok(ctx=None) - except OcamlBridgeError: - pass - _logger.info("Injected %d page helpers into OCaml kernel", - sum(1 for n, f in helpers.items() - if callable(f) and not n.startswith("~"))) + count = 0 + for name, fn in helpers.items(): + if callable(fn) and not name.startswith("~"): + try: + sig = inspect.signature(fn) + nargs = sum(1 for p in sig.parameters.values() + if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)) + except (ValueError, TypeError): + nargs = 2 + nargs = max(nargs, 1) + param_names = " ".join(chr(97 + i) for i in range(nargs)) + arg_list = " ".join(chr(97 + i) for i in range(nargs)) + sx_def = f'(define {name} (fn ({param_names}) (helper "{name}" {arg_list})))' + try: + self._send(f'(load-source "{_escape(sx_def)}")') + await self._read_until_ok(ctx=None) + count += 1 + except OcamlBridgeError: + pass + _logger.info("Injected %d page helpers into OCaml kernel", count) except Exception as e: _logger.warning("Helper injection failed: %s", e) self._helpers_injected = False diff --git a/sx/sxc/pages/sx_router.py b/sx/sxc/pages/sx_router.py index 4833d85..175e556 100644 --- a/sx/sxc/pages/sx_router.py +++ b/sx/sxc/pages/sx_router.py @@ -90,7 +90,7 @@ async def eval_sx_url(raw_path: str) -> Any: from shared.sx.jinja_bridge import get_component_env, _get_request_context from shared.sx.pages import get_page, get_page_helpers, _eval_slot from shared.sx.types import Symbol, Keyword - from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response + from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response, sx_page from shared.sx.page import get_template_context from shared.browser.app.utils.htmx import is_htmx_request from shared.sx.ref.sx_ref import prepare_url_expr @@ -200,16 +200,44 @@ async def eval_sx_url(raw_path: str) -> Any: from shared.sx.parser import serialize from shared.sx.types import SxExpr bridge = await get_bridge() - sx_text = serialize(wrapped_ast) ocaml_ctx = {"_helper_service": "sx"} - content_sx = SxExpr(await bridge.aser(sx_text, ctx=ocaml_ctx)) + + if is_htmx_request(): + # HTMX: aser_slot the content, wrap in OOB layout + content_sx = SxExpr(await bridge.aser_slot( + serialize(wrapped_ast), ctx=ocaml_ctx)) + return sx_response(await oob_page_sx(content=content_sx)) + else: + # Full page: build layout+content AST and aser_slot + # in ONE pass — avoids double-aser that breaks when + # re-parsed content contains islands/reactive symbols. + full_ast = [ + Symbol("~shared:layout/app-body"), + Keyword("content"), wrapped_ast, + ] + full_text = serialize(full_ast) + has_nl = chr(10) in full_text + if has_nl: + logger.error("NEWLINE in aser_slot input at char %d!", + full_text.index(chr(10))) + import time as _time + _t0 = _time.monotonic() + body_sx = SxExpr(await bridge.aser_slot( + full_text, ctx=ocaml_ctx)) + _elapsed = _time.monotonic() - _t0 + logger.info("aser_slot: %.1fs, input=%d chars, output=%d chars, starts=%s", + _elapsed, len(full_text), len(body_sx), + str(body_sx)[:100]) + tctx = await get_template_context() + return await make_response( + await sx_page(tctx, body_sx), 200) else: content_sx = await _eval_slot(wrapped_ast, env, ctx) except Exception as e: logger.error("SX URL render failed for %s: %s", raw_path, e, exc_info=True) return None - # Return response — Python wraps in page shell (CSS, scripts, headers) + # Return response (Python path) if is_htmx_request(): return sx_response(await oob_page_sx(content=content_sx)) else: diff --git a/sx/tests/test_demos.py b/sx/tests/test_demos.py index 7978083..90fbbea 100644 --- a/sx/tests/test_demos.py +++ b/sx/tests/test_demos.py @@ -537,6 +537,32 @@ class TestHomePage: assert not fatal, f"JS errors after navigation: {fatal}" + def test_navigate_geography_to_reactive(self, page: Page): + """Click Reactive Islands from Geography — content must render.""" + errors = [] + page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}")) + nav(page, "(geography)") + ri_link = page.locator("a[sx-push-url]:has-text('Reactive Islands')").first + expect(ri_link).to_be_visible(timeout=10000) + ri_link.click() + page.wait_for_timeout(5000) + expect(page.locator("#main-panel")).to_contain_text("Reactive Islands", timeout=10000) + fatal = [e for e in errors if "Not callable" in e or "Undefined symbol" in e or "UNCAUGHT" in e] + assert not fatal, f"JS errors after navigation: {fatal}" + + def test_direct_load_reactive_page(self, page: Page): + """Direct load of reactive islands page — no errors, content renders.""" + errors = [] + page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}")) + page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None) + page.goto(f"{BASE}/sx/(geography.(reactive))", wait_until="networkidle") + # aser_slot may take several seconds on first load — wait for render + page.wait_for_selector("#main-panel", timeout=30000) + expect(page.locator("#main-panel")).to_contain_text("Reactive Islands", timeout=10000) + fatal = [e for e in errors if "Not callable" in e or "Undefined symbol" in e or "SES_UNCAUGHT" in e or "UNCAUGHT" in e] + assert not fatal, f"JS errors on reactive page: {fatal}" + + class TestDocPages: @pytest.mark.parametrize("path,expected", [ ("(geography.(reactive))", "Reactive Islands"),