Fix pipe desync: send_ok_raw escapes newlines, expand-components? in env

- send_ok_raw: when SX wire format contains newlines (string literals),
  fall back to (ok "...escaped...") instead of (ok-raw ...) to keep
  the pipe single-line. Prevents multi-line responses from desyncing
  subsequent requests.
- expand-components? flag set in kernel env (not just VM adapter globals)
  so aser-list's env-has? check finds it during component expansion.
- SX_STANDALONE: restore no_oauth but generate CSRF via session cookie
  so mutation handlers (DELETE etc.) still work without account service.
- Shell statics injection: only inject small values (hashes, URLs) as
  kernel vars. Large blobs (CSS, component_defs) use placeholder tokens.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 12:32:03 +00:00
parent ae0e87fbf8
commit 373a4f0134
3 changed files with 43 additions and 23 deletions

View File

@@ -71,6 +71,22 @@ let send_ok_value v = send (Printf.sprintf "(ok %s)" (serialize_value v))
let send_ok_string s = send (Printf.sprintf "(ok \"%s\")" (escape_sx_string s)) let send_ok_string s = send (Printf.sprintf "(ok \"%s\")" (escape_sx_string s))
let send_error msg = send (Printf.sprintf "(error \"%s\")" (escape_sx_string msg)) let send_error msg = send (Printf.sprintf "(error \"%s\")" (escape_sx_string msg))
(** Send ok-raw, ensuring single-line output.
SX wire format from aser may contain newlines inside string literals.
We must escape those to prevent pipe desync (Python reads one line
at a time), but we can't blindly replace newlines in the raw SX
because that would break string content.
Strategy: wrap as a properly escaped string literal.
Python side will unescape it. *)
let send_ok_raw s =
(* If the result has no newlines, send as-is for backward compat *)
if not (String.contains s '\n') then
send (Printf.sprintf "(ok-raw %s)" s)
else
(* Wrap as escaped string so newlines are preserved *)
send (Printf.sprintf "(ok \"%s\")" (escape_sx_string s))
(* ====================================================================== *) (* ====================================================================== *)
(* IO bridge — primitives that yield to Python *) (* IO bridge — primitives that yield to Python *)
@@ -806,7 +822,7 @@ let dispatch env cmd =
| RawHTML s -> "\"" ^ escape_sx_string s ^ "\"" | RawHTML s -> "\"" ^ escape_sx_string s ^ "\""
| _ -> "nil" | _ -> "nil"
in in
send (Printf.sprintf "(ok-raw %s)" (raw_serialize result)) send_ok_raw (raw_serialize result)
with with
| Eval_error msg -> send_error msg | Eval_error msg -> send_error msg
| exn -> send_error (Printexc.to_string exn)) | exn -> send_error (Printexc.to_string exn))
@@ -830,7 +846,7 @@ let dispatch env cmd =
(* Send raw SX wire format without re-escaping. (* Send raw SX wire format without re-escaping.
Use (ok-raw ...) so Python knows not to unescape. *) Use (ok-raw ...) so Python knows not to unescape. *)
(match result with (match result with
| String s | SxExpr s -> send (Printf.sprintf "(ok-raw %s)" s) | String s | SxExpr s -> send_ok_raw s
| _ -> send_ok_value result) | _ -> send_ok_value result)
with with
| Eval_error msg -> send_error msg | Eval_error msg -> send_error msg
@@ -891,7 +907,7 @@ let dispatch env cmd =
let t2 = Unix.gettimeofday () in let t2 = Unix.gettimeofday () in
Printf.eprintf "[aser-slot] eval=%.1fs io_flush=%.1fs batched=%d result=%d chars\n%!" Printf.eprintf "[aser-slot] eval=%.1fs io_flush=%.1fs batched=%d result=%d chars\n%!"
(t1 -. t0) (t2 -. t1) n_batched (String.length final); (t1 -. t0) (t2 -. t1) n_batched (String.length final);
send (Printf.sprintf "(ok-raw %s)" final) send_ok_raw final
with with
| Eval_error msg -> | Eval_error msg ->
io_batch_mode := false; io_batch_mode := false;

View File

@@ -170,23 +170,20 @@ class OcamlBridge:
static = _get_shell_static() static = _get_shell_static()
except Exception: except Exception:
return # not ready yet (no app context) return # not ready yet (no app context)
# Define small values as kernel variables. # Only inject small, safe values as kernel variables.
# Large blobs (component_defs, pages_sx, init_sx) use placeholders # Large/complex blobs use placeholder tokens at render time.
# at render time — NOT injected here. for key in ("component_hash", "sx_css_classes", "asset_url",
for key in ("sx_css", "component_hash", "sx_css_classes", "asset_url",
"sx_js_hash", "body_js_hash"): "sx_js_hash", "body_js_hash"):
val = static.get(key, "") val = static.get(key) or ""
if val is None:
val = ""
var = f"__shell-{key.replace('_', '-')}" var = f"__shell-{key.replace('_', '-')}"
defn = f'(define {var} "{_escape(str(val))}")' defn = f'(define {var} "{_escape(str(val))}")'
try: try:
await self._send(f'(load-source "{_escape(defn)}")') await self._send(f'(load-source "{_escape(defn)}")')
await self._read_until_ok(ctx=None) await self._read_until_ok(ctx=None)
except OcamlBridgeError: except OcamlBridgeError as e:
pass _logger.warning("Shell static inject failed for %s: %s", key, e)
# Also inject list/nil values # List/nil values
for key in ("head_scripts", "inline_css", "inline_head_js", "body_scripts"): for key in ("head_scripts", "body_scripts"):
val = static.get(key) val = static.get(key)
var = f"__shell-{key.replace('_', '-')}" var = f"__shell-{key.replace('_', '-')}"
if val is None: if val is None:
@@ -199,8 +196,8 @@ class OcamlBridge:
try: try:
await self._send(f'(load-source "{_escape(defn)}")') await self._send(f'(load-source "{_escape(defn)}")')
await self._read_until_ok(ctx=None) await self._read_until_ok(ctx=None)
except OcamlBridgeError: except OcamlBridgeError as e:
pass _logger.warning("Shell static inject failed for %s: %s", key, e)
self._shell_statics_injected = True self._shell_statics_injected = True
_logger.info("Injected shell statics into OCaml kernel") _logger.info("Injected shell statics into OCaml kernel")
@@ -220,14 +217,16 @@ class OcamlBridge:
async with self._lock: async with self._lock:
await self._inject_helpers_locked() await self._inject_helpers_locked()
await self._inject_shell_statics_locked() await self._inject_shell_statics_locked()
# Large blobs (component_defs, pages_sx, init_sx) use placeholders. # Large/complex blobs use placeholders — OCaml renders the shell
# OCaml renders the shell with short tokens; Python splices in # with short tokens; Python splices in the real values post-render.
# the real values. This avoids piping ~1MB through stdin/stdout. # This avoids piping large strings or strings with special chars
PLACEHOLDER_KEYS = {"component_defs", "pages_sx", "init_sx"} # through the SX parser.
PLACEHOLDER_KEYS = {"component_defs", "pages_sx", "init_sx",
"sx_css", "inline_css", "inline_head_js"}
placeholders = {} placeholders = {}
static_keys = {"component_hash", "sx_css_classes", "asset_url", static_keys = {"component_hash", "sx_css_classes", "asset_url",
"sx_js_hash", "body_js_hash", "sx_css", "sx_js_hash", "body_js_hash",
"head_scripts", "inline_css", "inline_head_js", "body_scripts"} "head_scripts", "body_scripts"}
parts = [f'(sx-page-full "{_escape(page_source)}"'] parts = [f'(sx-page-full "{_escape(page_source)}"']
for key, val in shell_kwargs.items(): for key, val in shell_kwargs.items():
k = key.replace("_", "-") k = key.replace("_", "-")

View File

@@ -49,6 +49,11 @@ async def sx_standalone_context() -> dict:
ctx["cart_mini"] = "" ctx["cart_mini"] = ""
ctx["auth_menu"] = "" ctx["auth_menu"] = ""
ctx["nav_tree"] = "" ctx["nav_tree"] = ""
# Generate CSRF token — standalone has no account service but still
# needs CSRF for mutation handlers (DELETE etc.)
from shared.browser.app.csrf import generate_csrf_token
from quart import g
g.csrf_token = generate_csrf_token()
return ctx return ctx
@@ -57,8 +62,8 @@ def create_app() -> "Quart":
extra_kw = {} extra_kw = {}
if SX_STANDALONE: if SX_STANDALONE:
extra_kw["no_oauth"] = True
extra_kw["no_db"] = True extra_kw["no_db"] = True
extra_kw["no_oauth"] = True
app = create_base_app( app = create_base_app(
"sx", "sx",