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:
@@ -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;
|
||||||
|
|||||||
@@ -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("_", "-")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user