OCaml kernel (sx_server.ml):
- Batch IO mode for aser-slot: batchable helpers (highlight,
component-source) return placeholders during evaluation instead
of blocking on stdin. After aser completes, all batched requests
are flushed to Python at once.
- Python processes them concurrently with asyncio.gather.
- Placeholders (using «IO:N» markers) are replaced with actual
values in the result string.
- Non-batchable IO (query, action, ctx, request-arg) still uses
blocking mode — their results drive control flow.
Python bridge (ocaml_bridge.py):
- _read_until_ok handles batched protocol: collects io-request
lines with numeric IDs, processes on (io-done N) with gather.
- IO result cache for pure helpers — eliminates redundant calls.
- _handle_io_request strips batch ID from request format.
Component caching (jinja_bridge.py):
- Hash computed from FULL component env (all names + bodies),
not per-page subset. Stable across all pages — browser caches
once, no re-download on navigation between pages.
- invalidate_component_hash() called on hot-reload.
Tests: 15/15 OCaml helper tests pass (2 new batch IO tests).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: OcamlBridge._send() used write() without drain().
asyncio.StreamWriter buffers writes — without drain(), multiple
commands accumulate and flush as a batch. The kernel processes
them sequentially, sending responses, but Python only reads one
response per command → pipe desync → "unexpected response" errors.
Fix: _send() is now async, calls drain() after every write.
All 14 callers updated to await.
Playwright tests rewritten:
- test_home_has_header: verifies #logo-opacity visible (was only
checking for "sx" text — never caught missing header)
- test_home_has_nav_children: Geography link must be visible
- test_home_has_main_panel: #main-panel must have child elements
- TestDirectPageLoad: fresh browser.new_context() per test to
avoid stale component hash in localStorage
- _setup_error_capture + _check_no_fatal_errors helpers
_render_to_sx uses aser_slot (not aser) — layout wrappers contain
re-parsed content that needs full expansion capability.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fix: islands (defisland) pass component? check but must NEVER be
expanded server-side — they use browser-only reactive primitives
(signal, deref, computed). Added (not (island? comp)) guard in
adapter-sx.sx aser component dispatch.
New test file: shared/sx/tests/test_ocaml_helpers.py
- TestHelperInjection: 5 tests — helper IO proxy, 2-arg calls,
aser/aser_slot with helpers, undefined helper error
- TestHelperIOPerformance: 2 tests — 20 sequential IO round-trips
complete in <5s, aser_slot with 5 helpers in <3s
- TestAserSlotClientAffinity: 6 tests — island exclusion, client
affinity exclusion, server affinity expansion, auto affinity
behavior in aser vs aser_slot
eval_sx_url stays on bridge.aser() (server-affinity only) for now.
Switching to aser_slot requires fixing the double-aser issue in
_render_to_sx where content gets re-parsed and re-asered.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>