Skip to main content

lex_runtime/
handler.rs

1//! Native effect handlers, dispatched at runtime through the VM's
2//! `EffectHandler` trait. The handler also re-checks the runtime policy
3//! per spec §7.4 (the static check is necessary but not sufficient: a fn
4//! declared `[fs_read("/data")]` that's allowed at startup still has to
5//! pass the path check at the point of dispatch).
6
7use lex_bytecode::vm::{EffectHandler, Vm};
8use lex_bytecode::{Program, Value};
9use std::path::PathBuf;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::sync::{Mutex, OnceLock};
12use std::sync::Arc;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use crate::builtins::{call_pure_builtin, is_pure_call};
16use crate::policy::Policy;
17
18/// Output sink used by `io.print`. Tests inject a buffer; production prints
19/// to stdout.
20pub trait IoSink: Send {
21    fn print_line(&mut self, s: &str);
22}
23
24pub struct StdoutSink;
25impl IoSink for StdoutSink {
26    fn print_line(&mut self, s: &str) {
27        use std::io::Write;
28        println!("{s}");
29        let _ = std::io::stdout().flush();
30    }
31}
32
33#[derive(Default)]
34pub struct CapturedSink { pub lines: Vec<String> }
35impl IoSink for CapturedSink {
36    fn print_line(&mut self, s: &str) { self.lines.push(s.to_string()); }
37}
38
39/// `agent.cloud_stream` registry: per-handle producer iterators
40/// keyed by opaque handle id (#305 slice 3).
41pub type StreamRegistry =
42    std::collections::HashMap<String, Box<dyn Iterator<Item = String> + Send>>;
43
44pub struct DefaultHandler {
45    policy: Policy,
46    pub sink: Box<dyn IoSink>,
47    /// Optional read root for `io.read` — when set, `io.read("p")` resolves
48    /// to `read_root.join(p)`. Lets tests run without touching the real fs.
49    pub read_root: Option<PathBuf>,
50    /// Per-run budget pool (#225). `Arc<AtomicU64>` so parallel
51    /// branches share one counter without locking. Initialized to
52    /// the policy ceiling at handler construction; each call to a
53    /// function with declared `[budget(N)]` deducts N atomically
54    /// via `note_call_budget`. Cloning the handler is intentional
55    /// for net.serve / chat handlers — they share the same pool.
56    pub budget_remaining: Arc<AtomicU64>,
57    /// The original ceiling that `budget_remaining` started at, kept
58    /// for diagnostics so a `BudgetExceeded` error can report
59    /// `(used, ceiling)` rather than just "exceeded by N".
60    pub budget_ceiling: Option<u64>,
61    /// Shared reference to the program, needed by `net.serve` so the
62    /// handler can spin up fresh VMs to dispatch incoming requests.
63    /// `None` if the handler was constructed without a program.
64    pub program: Option<Arc<Program>>,
65    /// Chat registry; populated by `net.serve_ws`'s per-message
66    /// dispatch so `chat.broadcast` / `chat.send` work from inside
67    /// a handler invocation.
68    pub chat_registry: Option<Arc<crate::ws::ChatRegistry>>,
69    /// LRU cache of `agent.call_mcp` clients keyed by the
70    /// command-line string (#197). Avoids spawn-per-call cost
71    /// when an agent invokes the same MCP server in tight loops.
72    /// Capped — when the cache is full, the least-recently-used
73    /// entry is dropped (its subprocess is reaped on Drop).
74    pub mcp_clients: crate::mcp_client::McpClientCache,
75    /// Stream registry for `agent.cloud_stream` / `stream.next` /
76    /// `stream.collect` (#305 slice 3). Keyed by an opaque handle
77    /// id; values are the producer iterators. Wrapped in
78    /// `Arc<Mutex<…>>` so par_map workers can share the same
79    /// stream pool (when slice-2's per-worker handler split chains
80    /// the registry through).
81    pub streams: Arc<std::sync::Mutex<StreamRegistry>>,
82    /// Monotonic counter for handing out fresh stream handle ids.
83    pub next_stream_id: Arc<std::sync::atomic::AtomicU64>,
84    /// Stack of per-request arenas (#463 scaffolding). One entry
85    /// per active request scope; `net.serve_fn`'s request loop
86    /// pushes on entry, pops on exit. Today nothing reads from the
87    /// arenas — they're scaffolding for the Value-rep follow-on
88    /// that routes `MakeRecord` / `MakeList` allocations into the
89    /// active arena. See `crates/lex-runtime/src/arena.rs`.
90    ///
91    /// Held by value (not Arc) so worker-clone handlers
92    /// (`spawn_for_worker`) get a fresh empty stack rather than
93    /// sharing the parent's arenas — worker-thread allocations
94    /// have a different lifetime than the request that spawned
95    /// them.
96    arena_stack: Vec<(u64, crate::arena::Arena)>,
97    /// Monotonic counter for the scope ids handed out by
98    /// `enter_request_scope`. `enter` returns a fresh id; `exit`
99    /// finds and removes the matching entry. Plain `u64`, not
100    /// shared — each handler instance has its own counter.
101    next_scope_id: u64,
102    /// Arguments passed after `--` in `lex run <file> -- [args...]`.
103    /// Returned by `io.argv()` so Lex `main` functions can read CLI flags.
104    pub program_args: Vec<String>,
105}
106
107impl DefaultHandler {
108    pub fn new(policy: Policy) -> Self {
109        // If the caller supplied a ceiling, the pool starts at that
110        // ceiling and counts down. No ceiling = `u64::MAX` so calls
111        // never refuse on budget grounds (existing behavior).
112        let ceiling = policy.budget;
113        let initial = ceiling.unwrap_or(u64::MAX);
114        Self {
115            policy,
116            sink: Box::new(StdoutSink),
117            read_root: None,
118            budget_remaining: Arc::new(AtomicU64::new(initial)),
119            budget_ceiling: ceiling,
120            program: None,
121            chat_registry: None,
122            mcp_clients: crate::mcp_client::McpClientCache::with_capacity(16),
123            streams: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
124            next_stream_id: Arc::new(std::sync::atomic::AtomicU64::new(0)),
125            arena_stack: Vec::new(),
126            next_scope_id: 1,
127            program_args: Vec::new(),
128        }
129    }
130
131    /// Read-only access to the currently-active request arena, if
132    /// any. `None` outside a request scope. The follow-on slice
133    /// that routes `Value` allocations consults this from the VM
134    /// path; today it has no callers in tree but is exercised in
135    /// tests.
136    pub fn active_arena(&self) -> Option<&crate::arena::Arena> {
137        self.arena_stack.last().map(|(_, a)| a)
138    }
139
140    /// Test-only: depth of the arena stack. Lets tests confirm the
141    /// `net.serve_fn` request loop pushes/pops symmetrically.
142    pub fn arena_stack_depth(&self) -> usize {
143        self.arena_stack.len()
144    }
145
146    pub fn with_program(mut self, program: Arc<Program>) -> Self {
147        self.program = Some(program); self
148    }
149
150    pub fn with_chat_registry(mut self, registry: Arc<crate::ws::ChatRegistry>) -> Self {
151        self.chat_registry = Some(registry); self
152    }
153
154    pub fn with_sink(mut self, sink: Box<dyn IoSink>) -> Self {
155        self.sink = sink; self
156    }
157
158    pub fn with_read_root(mut self, root: PathBuf) -> Self {
159        self.read_root = Some(root); self
160    }
161
162    pub fn with_program_args(mut self, args: Vec<String>) -> Self {
163        self.program_args = args; self
164    }
165
166    fn ensure_kind_allowed(&self, kind: &str) -> Result<(), String> {
167        if self.policy.allow_effects.contains(kind) {
168            Ok(())
169        } else {
170            Err(format!("effect `{kind}` not in --allow-effects"))
171        }
172    }
173
174    fn resolve_read_path(&self, p: &str) -> PathBuf {
175        match &self.read_root {
176            Some(root) => root.join(p.trim_start_matches('/')),
177            None => PathBuf::from(p),
178        }
179    }
180
181    fn dispatch_log(&mut self, op: &str, args: Vec<Value>) -> Result<Value, String> {
182        match op {
183            "debug" | "info" | "warn" | "error" => {
184                let msg = expect_str(args.first())?;
185                let level = match op {
186                    "debug" => LogLevel::Debug,
187                    "info" => LogLevel::Info,
188                    "warn" => LogLevel::Warn,
189                    _ => LogLevel::Error,
190                };
191                emit_log(level, msg);
192                Ok(Value::Unit)
193            }
194            "set_level" => {
195                let s = expect_str(args.first())?;
196                match parse_log_level(s) {
197                    Some(l) => {
198                        log_state().lock().unwrap().level = l;
199                        Ok(ok(Value::Unit))
200                    }
201                    None => Ok(err(Value::Str(format!(
202                        "log.set_level: unknown level `{s}`; expected debug|info|warn|error").into()))),
203                }
204            }
205            "set_format" => {
206                let s = expect_str(args.first())?;
207                let fmt = match s {
208                    "text" => LogFormat::Text,
209                    "json" => LogFormat::Json,
210                    other => return Ok(err(Value::Str(format!(
211                        "log.set_format: unknown format `{other}`; expected text|json").into()))),
212                };
213                log_state().lock().unwrap().format = fmt;
214                Ok(ok(Value::Unit))
215            }
216            "set_sink" => {
217                let path = expect_str(args.first())?;
218                if path == "-" {
219                    log_state().lock().unwrap().sink = LogSink::Stderr;
220                    return Ok(ok(Value::Unit));
221                }
222                if let Err(e) = self.ensure_fs_write_path(path) {
223                    return Ok(err(Value::Str(e.into())));
224                }
225                match std::fs::OpenOptions::new()
226                    .create(true).append(true).open(path)
227                {
228                    Ok(f) => {
229                        log_state().lock().unwrap().sink = LogSink::File(std::sync::Arc::new(Mutex::new(f)));
230                        Ok(ok(Value::Unit))
231                    }
232                    Err(e) => Ok(err(Value::Str(format!("log.set_sink `{path}`: {e}").into()))),
233                }
234            }
235            other => Err(format!("unsupported log.{other}")),
236        }
237    }
238
239    fn dispatch_process(&mut self, op: &str, args: Vec<Value>) -> Result<Value, String> {
240        match op {
241            "spawn" => {
242                let cmd = expect_str(args.first())?.to_string();
243                let raw_args = match args.get(1) {
244                    Some(Value::List(items)) => items.clone(),
245                    _ => return Err("process.spawn: args must be List[Str]".into()),
246                };
247                let str_args: Result<Vec<String>, String> = raw_args.iter().map(|v| match v {
248                    Value::Str(s) => Ok(s.to_string()),
249                    other => Err(format!("process.spawn: arg must be Str, got {other:?}")),
250                }).collect();
251                let str_args = str_args?;
252                let opts = match args.get(2) {
253                    Some(Value::Record { fields: r, .. }) => r.clone(),
254                    _ => return Err("process.spawn: missing or invalid opts record".into()),
255                };
256
257                // Allow-list check, mirroring process.run below.
258                if !self.policy.allow_proc.is_empty() {
259                    let basename = std::path::Path::new(&cmd)
260                        .file_name()
261                        .and_then(|s| s.to_str())
262                        .unwrap_or(&cmd);
263                    if !self.policy.allow_proc.iter().any(|a| a == basename) {
264                        return Ok(err(Value::Str(format!(
265                            "process.spawn: `{cmd}` not in --allow-proc {:?}",
266                            self.policy.allow_proc
267                        ).into())));
268                    }
269                }
270
271                let mut command = std::process::Command::new(&cmd);
272                command.args(&str_args);
273                command.stdin(std::process::Stdio::piped());
274                command.stdout(std::process::Stdio::piped());
275                command.stderr(std::process::Stdio::piped());
276
277                if let Some(Value::Variant { name, args: vargs }) = opts.get("cwd") {
278                    if name == "Some" {
279                        if let Some(Value::Str(s)) = vargs.first() {
280                            command.current_dir(s);
281                        }
282                    }
283                }
284                if let Some(Value::Map(env)) = opts.get("env") {
285                    for (k, v) in env {
286                        if let (lex_bytecode::MapKey::Str(ks), Value::Str(vs)) = (k, v) {
287                            command.env(ks, vs);
288                        }
289                    }
290                }
291
292                let stdin_payload: Option<Vec<u8>> = match opts.get("stdin") {
293                    Some(Value::Variant { name, args: vargs }) if name == "Some" => {
294                        match vargs.first() {
295                            Some(Value::Bytes(b)) => Some(b.clone()),
296                            _ => None,
297                        }
298                    }
299                    _ => None,
300                };
301
302                let mut child = match command.spawn() {
303                    Ok(c) => c,
304                    Err(e) => return Ok(err(Value::Str(format!("process.spawn `{cmd}`: {e}").into()))),
305                };
306
307                if let Some(payload) = stdin_payload {
308                    if let Some(mut stdin) = child.stdin.take() {
309                        use std::io::Write;
310                        let _ = stdin.write_all(&payload);
311                        // Drop closes stdin; the child sees EOF.
312                    }
313                }
314
315                let stdout = child.stdout.take().map(std::io::BufReader::new);
316                let stderr = child.stderr.take().map(std::io::BufReader::new);
317                let handle = next_process_handle();
318                process_registry().lock().unwrap().insert(handle, ProcessState {
319                    child,
320                    stdout,
321                    stderr,
322                });
323                Ok(ok(Value::Int(handle as i64)))
324            }
325            "read_stdout_line" => Self::read_line_op(args, true),
326            "read_stderr_line" => Self::read_line_op(args, false),
327            "wait" => {
328                let h = expect_process_handle(args.first())?;
329                // Look up the per-handle Arc, then release the global
330                // lock before the (slow) wait so unrelated handles
331                // can dispatch concurrently.
332                let arc = process_registry().lock().unwrap()
333                    .touch_get(h)
334                    .ok_or_else(|| "process.wait: closed or unknown ProcessHandle".to_string())?;
335                let status = {
336                    let mut state = arc.lock().unwrap();
337                    state.child.wait().map_err(|e| format!("process.wait: {e}"))?
338                };
339                // Wait completion makes the handle terminal; drop it
340                // from the registry so the cap doesn't fill up with
341                // exited children.
342                process_registry().lock().unwrap().remove(h);
343                let mut rec = indexmap::IndexMap::new();
344                rec.insert("code".into(), Value::Int(status.code().unwrap_or(-1) as i64));
345                #[cfg(unix)]
346                {
347                    use std::os::unix::process::ExitStatusExt;
348                    rec.insert("signaled".into(), Value::Bool(status.signal().is_some()));
349                }
350                #[cfg(not(unix))]
351                {
352                    rec.insert("signaled".into(), Value::Bool(false));
353                }
354                Ok(Value::record_dynamic(rec))
355            }
356            "kill" => {
357                let h = expect_process_handle(args.first())?;
358                let _signal = expect_str(args.get(1))?;
359                let arc = process_registry().lock().unwrap()
360                    .touch_get(h)
361                    .ok_or_else(|| "process.kill: closed or unknown ProcessHandle".to_string())?;
362                let mut state = arc.lock().unwrap();
363                // Cross-platform: only `kill` (SIGKILL-equivalent on
364                // Windows). Signal-name dispatch is a v1.5 follow-up.
365                match state.child.kill() {
366                    Ok(_) => Ok(ok(Value::Unit)),
367                    Err(e) => Ok(err(Value::Str(format!("process.kill: {e}").into()))),
368                }
369            }
370            "run" => {
371                let cmd = expect_str(args.first())?.to_string();
372                let raw_args = match args.get(1) {
373                    Some(Value::List(items)) => items.clone(),
374                    _ => return Err("process.run: args must be List[Str]".into()),
375                };
376                let str_args: Result<Vec<String>, String> = raw_args.iter().map(|v| match v {
377                    Value::Str(s) => Ok(s.to_string()),
378                    other => Err(format!("process.run: arg must be Str, got {other:?}")),
379                }).collect();
380                let str_args = str_args?;
381                if !self.policy.allow_proc.is_empty() {
382                    let basename = std::path::Path::new(&cmd)
383                        .file_name()
384                        .and_then(|s| s.to_str())
385                        .unwrap_or(&cmd);
386                    if !self.policy.allow_proc.iter().any(|a| a == basename) {
387                        return Ok(err(Value::Str(format!(
388                            "process.run: `{cmd}` not in --allow-proc {:?}",
389                            self.policy.allow_proc
390                        ).into())));
391                    }
392                }
393                match std::process::Command::new(&cmd).args(&str_args).output() {
394                    Ok(o) => {
395                        let mut rec = indexmap::IndexMap::new();
396                        rec.insert("stdout".into(), Value::Str(
397                            String::from_utf8_lossy(&o.stdout).into_owned().into()));
398                        rec.insert("stderr".into(), Value::Str(
399                            String::from_utf8_lossy(&o.stderr).into_owned().into()));
400                        rec.insert("exit_code".into(), Value::Int(
401                            o.status.code().unwrap_or(-1) as i64));
402                        Ok(ok(Value::record_dynamic(rec)))
403                    }
404                    Err(e) => Ok(err(Value::Str(format!("process.run `{cmd}`: {e}").into()))),
405                }
406            }
407            other => Err(format!("unsupported process.{other}")),
408        }
409    }
410
411    /// Read one line from the child's stdout (`is_stdout = true`) or
412    /// stderr. Returns `None` (Lex `Option`) at EOF; subsequent calls
413    /// keep returning `None`. Holds only the per-handle mutex during
414    /// the (potentially blocking) read, so reads on one handle don't
415    /// block reads/waits on a different handle.
416    fn read_line_op(args: Vec<Value>, is_stdout: bool) -> Result<Value, String> {
417        let h = expect_process_handle(args.first())?;
418        let arc = process_registry().lock().unwrap()
419            .touch_get(h)
420            .ok_or_else(|| format!(
421                "process.read_{}_line: closed or unknown ProcessHandle",
422                if is_stdout { "stdout" } else { "stderr" }))?;
423        let mut state = arc.lock().unwrap();
424        let reader_opt = if is_stdout {
425            state.stdout.as_mut().map(|r| -> &mut dyn std::io::BufRead { r })
426        } else {
427            state.stderr.as_mut().map(|r| -> &mut dyn std::io::BufRead { r })
428        };
429        let reader = match reader_opt {
430            Some(r) => r,
431            None => return Ok(none()),
432        };
433        let mut line = String::new();
434        match reader.read_line(&mut line) {
435            Ok(0) => Ok(none()),
436            Ok(_) => {
437                if line.ends_with('\n') { line.pop(); }
438                if line.ends_with('\r') { line.pop(); }
439                Ok(some(Value::Str(line.into())))
440            }
441            Err(e) => Err(format!("process.read_*_line: {e}")),
442        }
443    }
444
445    fn dispatch_fs(&mut self, op: &str, args: Vec<Value>) -> Result<Value, String> {
446        match op {
447            "exists" => {
448                let path = expect_str(args.first())?.to_string();
449                if let Err(e) = self.ensure_fs_walk_path(&path) {
450                    return Ok(err(Value::Str(e.into())));
451                }
452                Ok(Value::Bool(std::path::Path::new(&path).exists()))
453            }
454            "is_file" => {
455                let path = expect_str(args.first())?.to_string();
456                if let Err(e) = self.ensure_fs_walk_path(&path) {
457                    return Ok(err(Value::Str(e.into())));
458                }
459                Ok(Value::Bool(std::path::Path::new(&path).is_file()))
460            }
461            "is_dir" => {
462                let path = expect_str(args.first())?.to_string();
463                if let Err(e) = self.ensure_fs_walk_path(&path) {
464                    return Ok(err(Value::Str(e.into())));
465                }
466                Ok(Value::Bool(std::path::Path::new(&path).is_dir()))
467            }
468            "stat" => {
469                let path = expect_str(args.first())?.to_string();
470                if let Err(e) = self.ensure_fs_walk_path(&path) {
471                    return Ok(err(Value::Str(e.into())));
472                }
473                match std::fs::metadata(&path) {
474                    Ok(md) => {
475                        let mtime = md.modified()
476                            .ok()
477                            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
478                            .map(|d| d.as_secs() as i64)
479                            .unwrap_or(0);
480                        let mut rec = indexmap::IndexMap::new();
481                        rec.insert("size".into(), Value::Int(md.len() as i64));
482                        rec.insert("mtime".into(), Value::Int(mtime));
483                        rec.insert("is_dir".into(), Value::Bool(md.is_dir()));
484                        rec.insert("is_file".into(), Value::Bool(md.is_file()));
485                        Ok(ok(Value::record_dynamic(rec)))
486                    }
487                    Err(e) => Ok(err(Value::Str(format!("fs.stat `{path}`: {e}").into()))),
488                }
489            }
490            "list_dir" => {
491                let path = expect_str(args.first())?.to_string();
492                if let Err(e) = self.ensure_fs_walk_path(&path) {
493                    return Ok(err(Value::Str(e.into())));
494                }
495                match std::fs::read_dir(&path) {
496                    Ok(rd) => {
497                        let mut entries: Vec<Value> = Vec::new();
498                        for ent in rd {
499                            match ent {
500                                Ok(e) => {
501                                    let p = e.path();
502                                    entries.push(Value::Str(p.to_string_lossy().into_owned().into()));
503                                }
504                                Err(e) => return Ok(err(Value::Str(format!("fs.list_dir: {e}").into()))),
505                            }
506                        }
507                        Ok(ok(Value::List(entries.into())))
508                    }
509                    Err(e) => Ok(err(Value::Str(format!("fs.list_dir `{path}`: {e}").into()))),
510                }
511            }
512            "walk" => {
513                let path = expect_str(args.first())?.to_string();
514                if let Err(e) = self.ensure_fs_walk_path(&path) {
515                    return Ok(err(Value::Str(e.into())));
516                }
517                let mut paths: Vec<Value> = Vec::new();
518                for ent in walkdir::WalkDir::new(&path) {
519                    match ent {
520                        Ok(e) => paths.push(Value::Str(
521                            e.path().to_string_lossy().into_owned().into())),
522                        Err(e) => return Ok(err(Value::Str(format!("fs.walk: {e}").into()))),
523                    }
524                }
525                Ok(ok(Value::List(paths.into())))
526            }
527            "glob" => {
528                let pattern = expect_str(args.first())?.to_string();
529                // Glob patterns can't be path-scoped at parse time
530                // (`**/*.rs` doesn't pin a directory); we filter the
531                // per-result paths after expansion against
532                // `--allow-fs-read`.
533                let entries = match glob::glob(&pattern) {
534                    Ok(e) => e,
535                    Err(e) => return Ok(err(Value::Str(format!("fs.glob: {e}").into()))),
536                };
537                let mut paths: Vec<Value> = Vec::new();
538                for ent in entries {
539                    match ent {
540                        Ok(p) => {
541                            let s = p.to_string_lossy().into_owned();
542                            if self.policy.allow_fs_read.is_empty()
543                                || self.policy.allow_fs_read.iter().any(|root| p.starts_with(root))
544                            {
545                                paths.push(Value::Str(s.into()));
546                            }
547                        }
548                        Err(e) => return Ok(err(Value::Str(format!("fs.glob: {e}").into()))),
549                    }
550                }
551                Ok(ok(Value::List(paths.into())))
552            }
553            "mkdir_p" => {
554                let path = expect_str(args.first())?.to_string();
555                if let Err(e) = self.ensure_fs_write_path(&path) {
556                    return Ok(err(Value::Str(e.into())));
557                }
558                match std::fs::create_dir_all(&path) {
559                    Ok(_) => Ok(ok(Value::Unit)),
560                    Err(e) => Ok(err(Value::Str(format!("fs.mkdir_p `{path}`: {e}").into()))),
561                }
562            }
563            "remove" => {
564                let path = expect_str(args.first())?.to_string();
565                if let Err(e) = self.ensure_fs_write_path(&path) {
566                    return Ok(err(Value::Str(e.into())));
567                }
568                let p = std::path::Path::new(&path);
569                let result = if p.is_dir() {
570                    std::fs::remove_dir_all(p)
571                } else {
572                    std::fs::remove_file(p)
573                };
574                match result {
575                    Ok(_) => Ok(ok(Value::Unit)),
576                    Err(e) => Ok(err(Value::Str(format!("fs.remove `{path}`: {e}").into()))),
577                }
578            }
579            "copy" => {
580                let src = expect_str(args.first())?.to_string();
581                let dst = expect_str(args.get(1))?.to_string();
582                if let Err(e) = self.ensure_fs_walk_path(&src) {
583                    return Ok(err(Value::Str(e.into())));
584                }
585                if let Err(e) = self.ensure_fs_write_path(&dst) {
586                    return Ok(err(Value::Str(e.into())));
587                }
588                match std::fs::copy(&src, &dst) {
589                    Ok(_) => Ok(ok(Value::Unit)),
590                    Err(e) => Ok(err(Value::Str(format!("fs.copy {src} -> {dst}: {e}").into()))),
591                }
592            }
593            other => Err(format!("unsupported fs.{other}")),
594        }
595    }
596
597    /// Path scope for walk-style operations. `[fs_walk]` reuses the
598    /// `--allow-fs-read` allowlist — listing a directory is an
599    /// information disclosure on the same path tree as reading file
600    /// content, so the same scope applies. Empty allowlist = any path.
601    fn ensure_fs_walk_path(&self, path: &str) -> Result<(), String> {
602        if self.policy.allow_fs_read.is_empty() {
603            return Ok(());
604        }
605        let p = std::path::Path::new(path);
606        if self.policy.allow_fs_read.iter().any(|a| p.starts_with(a)) {
607            Ok(())
608        } else {
609            Err(format!("fs path `{path}` outside --allow-fs-read"))
610        }
611    }
612
613    /// Path scope for mutating operations. `[fs_write]` uses the
614    /// existing `--allow-fs-write` allowlist.
615    fn ensure_fs_write_path(&self, path: &str) -> Result<(), String> {
616        if self.policy.allow_fs_write.is_empty() {
617            return Ok(());
618        }
619        let p = std::path::Path::new(path);
620        if self.policy.allow_fs_write.iter().any(|a| p.starts_with(a)) {
621            Ok(())
622        } else {
623            Err(format!("fs path `{path}` outside --allow-fs-write"))
624        }
625    }
626
627    /// Enforce `--allow-net-host` against an outgoing URL. Empty
628    /// allowlist = any host. Non-empty = the URL's host must match
629    /// (substring; port-agnostic) at least one entry.
630    fn ensure_host_allowed(&self, url: &str) -> Result<(), String> {
631        if self.policy.allow_net_host.is_empty() { return Ok(()); }
632        let host = extract_host(url).unwrap_or("");
633        if self.policy.allow_net_host.iter().any(|h| host == h) {
634            Ok(())
635        } else {
636            Err(format!(
637                "net call to host `{host}` not in --allow-net-host {:?}",
638                self.policy.allow_net_host,
639            ))
640        }
641    }
642}
643
644fn extract_host(url: &str) -> Option<&str> {
645    let rest = url
646        .strip_prefix("http://")
647        .or_else(|| url.strip_prefix("https://"))
648        .or_else(|| url.strip_prefix("redis://"))
649        .or_else(|| url.strip_prefix("rediss://"))
650        // `@user:pass@host:port` — strip auth prefix if present
651        .map(|r| r.split_once('@').map(|(_, after)| after).unwrap_or(r))?;
652    let host_port = match rest.find('/') {
653        Some(i) => &rest[..i],
654        None => rest,
655    };
656    Some(match host_port.rsplit_once(':') {
657        Some((h, _)) => h,
658        None => host_port,
659    })
660}
661
662impl EffectHandler for DefaultHandler {
663    /// Push a fresh per-request arena onto the stack (#463
664    /// scaffolding). Returns the scope id; pair with
665    /// `exit_request_scope(id)` to drop it.
666    fn enter_request_scope(&mut self) -> u64 {
667        let id = self.next_scope_id;
668        self.next_scope_id = self.next_scope_id.wrapping_add(1);
669        self.arena_stack.push((id, crate::arena::Arena::new()));
670        id
671    }
672
673    /// Drop the arena associated with `scope_id`. Mismatched pairs
674    /// (exit called with a scope id we don't recognize, or out-of-
675    /// order exit) are tolerated as no-ops rather than panicking —
676    /// runtime layer should pair them strictly but a stray exit
677    /// shouldn't crash a live server.
678    fn exit_request_scope(&mut self, scope_id: u64) {
679        if let Some(pos) = self.arena_stack.iter().position(|(id, _)| *id == scope_id) {
680            // Drop this entry and any later entries that escaped
681            // pairing (out-of-order exit). Order matters: pop in
682            // reverse so the most recent arena drops first, then
683            // its predecessor, etc.
684            self.arena_stack.truncate(pos);
685        }
686    }
687
688    /// Per-call budget enforcement (#225). VM calls this before
689    /// invoking any function whose signature declares `[budget(N)]`.
690    /// The cost N is deducted atomically from the shared pool;
691    /// returning `Err` aborts the call before any frame is pushed.
692    fn note_call_budget(&mut self, cost: u64) -> Result<(), String> {
693        // Skip the work entirely when no ceiling is configured —
694        // the pool is `u64::MAX` and would never trip.
695        let Some(ceiling) = self.budget_ceiling else { return Ok(()); };
696        // Compare-and-swap: speculatively subtract; if we'd
697        // underflow, return BudgetExceeded without mutating.
698        // Use SeqCst because parallel branches may race here and
699        // the relative ordering of "used so far" vs. "this call's
700        // cost" needs to be deterministic across threads.
701        loop {
702            let cur = self.budget_remaining.load(Ordering::SeqCst);
703            if cost > cur {
704                let used = ceiling.saturating_sub(cur);
705                return Err(format!(
706                    "budget exceeded: requested {cost}, used so far {used}, ceiling {ceiling}"));
707            }
708            let next = cur - cost;
709            // Conservative accounting: if the CAS races and loses,
710            // re-read and try again. No refund-on-failure path.
711            if self.budget_remaining.compare_exchange(cur, next,
712                Ordering::SeqCst, Ordering::SeqCst).is_ok() {
713                return Ok(());
714            }
715        }
716    }
717
718    fn dispatch(&mut self, kind: &str, op: &str, args: Vec<Value>) -> Result<Value, String> {
719        // Pure stdlib builtins (str, list, json, ...) bypass the policy
720        // gate — they have no observable side effects and aren't tracked
721        // by the type system as effects.
722        if is_pure_call(kind, op) {
723            return call_pure_builtin(kind, op, args);
724        }
725        // `std.fs` ops use the fine-grained `[fs_walk]` and `[fs_write]`
726        // effect kinds (distinct from the module name `fs`); the
727        // policy check uses the per-op kind, not the module's.
728        if kind == "process" {
729            self.ensure_kind_allowed("proc")?;
730            return self.dispatch_process(op, args);
731        }
732        if kind == "log" {
733            // Emit ops are [log]; config ops are [io] (set_sink also
734            // [fs_write]). The dispatch picks the right kind per op.
735            let effect_kind = match op {
736                "debug" | "info" | "warn" | "error" => "log",
737                "set_level" | "set_format" => "io",
738                "set_sink" => {
739                    self.ensure_kind_allowed("io")?;
740                    self.ensure_kind_allowed("fs_write")?;
741                    return self.dispatch_log(op, args);
742                }
743                other => return Err(format!("unsupported log.{other}")),
744            };
745            self.ensure_kind_allowed(effect_kind)?;
746            return self.dispatch_log(op, args);
747        }
748        if kind == "fs" {
749            let effect_kind = match op {
750                "exists" | "is_file" | "is_dir" | "stat"
751                | "list_dir" | "walk" | "glob" => "fs_walk",
752                "mkdir_p" | "remove" => "fs_write",
753                "copy" => {
754                    self.ensure_kind_allowed("fs_walk")?;
755                    self.ensure_kind_allowed("fs_write")?;
756                    return self.dispatch_fs(op, args);
757                }
758                other => return Err(format!("unsupported fs.{other}")),
759            };
760            self.ensure_kind_allowed(effect_kind)?;
761            return self.dispatch_fs(op, args);
762        }
763        // `crypto.random` is the lone effectful op in `std.crypto`. Its
764        // declared effect kind is `random` (fine-grained on purpose so
765        // `lex audit --effect random` flags every token-generating
766        // call), distinct from the `crypto` module name.
767        // datetime.now is the only effectful op in std.datetime;
768        // declared kind is `time`, matching the existing `time.now`.
769        if kind == "datetime" && op == "now" {
770            self.ensure_kind_allowed("time")?;
771            // LEX_TEST_NOW (Unix seconds) pins the clock for deterministic tests (#350).
772            if let Ok(s) = std::env::var("LEX_TEST_NOW") {
773                if let Ok(secs) = s.trim().parse::<i64>() {
774                    return Ok(Value::Int(secs.saturating_mul(1_000_000_000)));
775                }
776            }
777            let now = chrono::Utc::now();
778            let nanos = now.timestamp_nanos_opt().unwrap_or(i64::MAX);
779            return Ok(Value::Int(nanos));
780        }
781        if kind == "crypto" && op == "random" {
782            self.ensure_kind_allowed("random")?;
783            let n = expect_int(args.first())?;
784            if !(0..=1_048_576).contains(&n) {
785                return Err("crypto.random: n must be in 0..=1048576".into());
786            }
787            use rand::{rngs::SysRng, TryRng};
788            let mut buf = vec![0u8; n as usize];
789            SysRng.try_fill_bytes(&mut buf)
790                .map_err(|e| format!("crypto.random: OS RNG: {e}"))?;
791            return Ok(Value::Bytes(buf));
792        }
793        // crypto.random_str_hex(n) — N random bytes rendered as 2N
794        // lowercase hex chars (#382). The most common token-mint
795        // pattern (session ids, OAuth `state`, CSRF, request ids).
796        // Same `[random]` gate as `crypto.random`.
797        if kind == "crypto" && op == "random_str_hex" {
798            self.ensure_kind_allowed("random")?;
799            let n = expect_int(args.first())?;
800            if !(0..=1_048_576).contains(&n) {
801                return Err("crypto.random_str_hex: n must be in 0..=1048576".into());
802            }
803            use rand::{rngs::SysRng, TryRng};
804            let mut buf = vec![0u8; n as usize];
805            SysRng.try_fill_bytes(&mut buf)
806                .map_err(|e| format!("crypto.random_str_hex: OS RNG: {e}"))?;
807            return Ok(Value::Str(hex::encode(&buf).into()));
808        }
809        // crypto.p256_generate() — mint a fresh P-256 (ES256) secret
810        // key from the OS RNG (#651). Returns the 32-byte scalar as
811        // `Ok(Bytes)`. Same `[random]` gate as `crypto.random`: key
812        // minting stays visible to `lex audit --effect random`.
813        //
814        // We sample 32 bytes and let `SigningKey::from_slice` reject
815        // the (vanishingly rare, ~2^-32) out-of-range scalar rather
816        // than pulling in p256's own `rand_core` — that crate is on a
817        // different `rand_core` major than the workspace `rand`, so
818        // bridging RNG traits here would mean an extra dependency for
819        // no behavioural gain. Retry a handful of times so a one-in-
820        // four-billion miss never surfaces as a spurious `Err`.
821        if kind == "crypto" && op == "p256_generate" {
822            self.ensure_kind_allowed("random")?;
823            use p256::ecdsa::SigningKey;
824            use rand::{rngs::SysRng, TryRng};
825            for _ in 0..16 {
826                let mut buf = [0u8; 32];
827                SysRng.try_fill_bytes(&mut buf)
828                    .map_err(|e| format!("crypto.p256_generate: OS RNG: {e}"))?;
829                if let Ok(sk) = SigningKey::from_slice(&buf) {
830                    return Ok(ok(Value::Bytes(sk.to_bytes().to_vec())));
831                }
832            }
833            return Ok(err(Value::Str(
834                "crypto.p256_generate: failed to sample a valid scalar".into())));
835        }
836        // crypto.secp256k1_generate() — mint a fresh secp256k1 secret key
837        // from the OS RNG (#655) for EVM / EIP-712 / x402 signing. Returns
838        // the 32-byte scalar as `Ok(Bytes)`. Same `[random]` gate and
839        // sample-and-reject loop as `p256_generate` (the curve order is
840        // close enough to 2^256 that a miss is ~2^-128, but the loop
841        // keeps the contract identical).
842        if kind == "crypto" && op == "secp256k1_generate" {
843            self.ensure_kind_allowed("random")?;
844            use k256::ecdsa::SigningKey;
845            use rand::{rngs::SysRng, TryRng};
846            for _ in 0..16 {
847                let mut buf = [0u8; 32];
848                SysRng.try_fill_bytes(&mut buf)
849                    .map_err(|e| format!("crypto.secp256k1_generate: OS RNG: {e}"))?;
850                if let Ok(sk) = SigningKey::from_slice(&buf) {
851                    return Ok(ok(Value::Bytes(sk.to_bytes().to_vec())));
852                }
853            }
854            return Ok(err(Value::Str(
855                "crypto.secp256k1_generate: failed to sample a valid scalar".into())));
856        }
857        // `std.http` wire ops (send/get/post) gate on the `net`
858        // effect kind, not the module name. This matches the
859        // declared signature (`http.get :: Str -> [net] ...`) and
860        // keeps `--allow-effects net` doing the obvious thing for
861        // both `net.*` and `http.*` callers.
862        // `std.agent` (#184): the four runtime effects added for
863        // agent-style programs (`llm_local`, `llm_cloud`, `a2a`,
864        // `mcp`). The handlers are stubs — they enforce the
865        // declared-effect gate, return a sentinel `Ok` so traces
866        // record the call, and defer the real wire formats to
867        // downstream crates (`soft-agent` for `llm_*` and `a2a`)
868        // and #185 (MCP client wrapper).
869        if kind == "agent" {
870            let effect_kind = match op {
871                "local_complete" => "llm_local",
872                "cloud_complete" => "llm_cloud",
873                "cloud_stream"   => "llm_cloud",
874                "send_a2a"       => "a2a",
875                "call_mcp"       => "mcp",
876                other => return Err(format!("unsupported agent.{other}")),
877            };
878            self.ensure_kind_allowed(effect_kind)?;
879            // `call_mcp` runs through the LRU client cache
880            // (#197). `local_complete` / `cloud_complete` hit
881            // Ollama / OpenAI via env-var-driven configuration
882            // (#196); custom backends override at the
883            // EffectHandler layer rather than via a config file.
884            // `send_a2a` keeps its stub — that wire format
885            // lives in downstream `soft-a2a`.
886            return match op {
887                "call_mcp"       => Ok(self.dispatch_call_mcp(args)),
888                "local_complete" => Ok(dispatch_llm_local(args)),
889                "cloud_complete" => Ok(dispatch_llm_cloud(args)),
890                "cloud_stream"   => Ok(self.dispatch_cloud_stream(args)),
891                _ => Ok(ok(Value::Str(format!("<{effect_kind} stub>").into()))),
892            };
893        }
894        if kind == "stream" {
895            // #305 slice 3: consumer-side stream operations. Each
896            // op resolves the opaque handle in the parent handler's
897            // stream registry and pulls one or all items. The
898            // `stream` effect must be granted by policy; default
899            // policies for agent runs grant it alongside the
900            // producer effect (e.g. `llm_cloud`).
901            self.ensure_kind_allowed("stream")?;
902            return match op {
903                "next"    => Ok(self.dispatch_stream_next(args)),
904                "collect" => Ok(self.dispatch_stream_collect(args)),
905                other => Err(format!("unsupported stream.{other}")),
906            };
907        }
908        if kind == "http" && matches!(op, "send" | "get" | "post" | "stream_lines") {
909            self.ensure_kind_allowed("net")?;
910            return match op {
911                "send" => {
912                    let req = expect_record(args.first())?;
913                    Ok(http_send_record(self, req))
914                }
915                "get" => {
916                    let url = expect_str(args.first())?.to_string();
917                    self.ensure_host_allowed(&url)?;
918                    Ok(http_send_simple("GET", &url, None, "", None))
919                }
920                "post" => {
921                    let url = expect_str(args.first())?.to_string();
922                    let body = expect_bytes(args.get(1))?.clone();
923                    let content_type = expect_str(args.get(2))?.to_string();
924                    self.ensure_host_allowed(&url)?;
925                    Ok(http_send_simple("POST", &url, Some(body), &content_type, None))
926                }
927                "stream_lines" => {
928                    let url = expect_str(args.first())?.to_string();
929                    let headers_val = args.get(1).cloned().unwrap_or(Value::Map(Default::default()));
930                    let body = expect_str(args.get(2))?.to_string();
931                    self.ensure_host_allowed(&url)?;
932                    Ok(http_stream_lines_impl(self, &url, &headers_val, &body))
933                }
934                _ => unreachable!(),
935            };
936        }
937        // `arrow.read_csv` declares `[fs_read]`, not `[arrow]` — its effect
938        // string in the type system is `fs_read`. Intercept before the
939        // generic `ensure_kind_allowed(kind)` below so the policy check
940        // looks at `fs_read` rather than `arrow`. Same pattern as
941        // `http.{send,get,post}` mapping to `[net]` above.
942        if kind == "arrow" && op == "read_csv" {
943            self.ensure_kind_allowed("fs_read")?;
944            let path = expect_str(args.first())?.to_string();
945            let resolved = self.resolve_read_path(&path);
946            if !self.policy.allow_fs_read.is_empty()
947                && !self.policy.allow_fs_read.iter().any(|a| resolved.starts_with(a))
948            {
949                return Err(format!("arrow.read_csv: `{path}` outside --allow-fs-read"));
950            }
951            return match crate::arrow::read_csv_at(&resolved) {
952                Ok(v)  => Ok(ok(v)),
953                Err(e) => Ok(err(Value::Str(e.into()))),
954            };
955        }
956        // `arrow.read_parquet` and `arrow.read_parquet_cols` are the
957        // Parquet siblings of `read_csv`. Same `[fs_read]` effect, same
958        // path-scope check. `_cols` takes an extra `List[Str]` argument.
959        if kind == "arrow" && (op == "read_parquet" || op == "read_parquet_cols") {
960            self.ensure_kind_allowed("fs_read")?;
961            let path = expect_str(args.first())?.to_string();
962            let resolved = self.resolve_read_path(&path);
963            if !self.policy.allow_fs_read.is_empty()
964                && !self.policy.allow_fs_read.iter().any(|a| resolved.starts_with(a))
965            {
966                return Err(format!("arrow.{op}: `{path}` outside --allow-fs-read"));
967            }
968            let r = if op == "read_parquet" {
969                crate::arrow::read_parquet_at(&resolved)
970            } else {
971                let cols = match args.get(1) {
972                    Some(Value::List(items)) => {
973                        let mut out = Vec::with_capacity(items.len());
974                        for v in items.iter() {
975                            match v {
976                                Value::Str(s) => out.push(s.to_string()),
977                                other => return Err(format!(
978                                    "arrow.read_parquet_cols: column name not Str: {other:?}")),
979                            }
980                        }
981                        out
982                    }
983                    other => return Err(format!(
984                        "arrow.read_parquet_cols: expected List[Str], got {other:?}")),
985                };
986                crate::arrow::read_parquet_cols_at(&resolved, &cols)
987            };
988            return match r {
989                Ok(v) => Ok(ok(v)),
990                Err(e) => Ok(err(Value::Str(e.into()))),
991            };
992        }
993        // `arrow.write_parquet` and `arrow.write_csv` declare `[fs_write]`.
994        // Path scope uses `--allow-fs-write` (symmetric with `io.write`).
995        if kind == "arrow" && (op == "write_parquet" || op == "write_csv") {
996            self.ensure_kind_allowed("fs_write")?;
997            let table_v = args.first().cloned().unwrap_or(Value::Unit);
998            let rb = match &table_v {
999                Value::ArrowTable(t) => Arc::clone(t),
1000                other => return Err(format!("arrow.{op}: first arg must be arrow.Table, got {other:?}")),
1001            };
1002            let path = expect_str(args.get(1))?.to_string();
1003            if let Err(e) = self.ensure_fs_write_path(&path) {
1004                return Ok(err(Value::Str(format!("arrow.{op}: {e}").into())));
1005            }
1006            let r = if op == "write_parquet" {
1007                crate::arrow::write_parquet_at(&rb, std::path::Path::new(&path))
1008            } else {
1009                crate::arrow::write_csv_at(&rb, std::path::Path::new(&path))
1010            };
1011            return match r {
1012                Ok(_)  => Ok(ok(Value::Unit)),
1013                Err(e) => Ok(err(Value::Str(e.into()))),
1014            };
1015        }
1016        // `net.default_opts()` is a pure record constructor — typed
1017        // with `EffectSet::empty()` in builtins.rs. Bypass the generic
1018        // `ensure_kind_allowed("net")` gate so callers don't need to
1019        // declare `[net]` just to build a ServeOpts literal default.
1020        if kind == "net" && op == "default_opts" {
1021            return Ok(ServeOpts::lex_defaults().to_value());
1022        }
1023        // `tls.*` (#496) — TlsConfig constructors map to different
1024        // effect kinds than the namespace name suggests:
1025        //   `tls.from_pem_files` :: [fs_read]   (reads cert + key PEM)
1026        //   `tls.self_signed`    :: pure        (rcgen, in-memory)
1027        // Intercept before the generic `ensure_kind_allowed("tls")`
1028        // gate so policy can check the *real* effect. Same pattern
1029        // as the `http.{send,get,post}` arms above.
1030        if kind == "tls" {
1031            return match op {
1032                "from_pem_files" => {
1033                    self.ensure_kind_allowed("fs_read")?;
1034                    dispatch_tls_from_pem_files(self, args)
1035                }
1036                "self_signed" => dispatch_tls_self_signed(args),
1037                other => Err(format!("unsupported tls.{other}")),
1038            };
1039        }
1040        // `std.redis` ops all carry `[net]` in their declared effect sets,
1041        // not `[redis]`. Gate on `net` here and skip the generic kind-check
1042        // below, matching the `std.http` precedent.
1043        if kind == "redis" {
1044            self.ensure_kind_allowed("net")?;
1045        } else if kind == "rand" {
1046            // `std.rand.int_in` draws from the OS RNG → `[random]` effect,
1047            // the same gate as `crypto.random` (#677). No separate `rand`
1048            // effect grant exists.
1049            self.ensure_kind_allowed("random")?;
1050        } else {
1051            self.ensure_kind_allowed(kind)?;
1052        }
1053        match (kind, op) {
1054            ("io", "print") => {
1055                let line = expect_str(args.first())?;
1056                self.sink.print_line(line);
1057                Ok(Value::Unit)
1058            }
1059            ("io", "read") => {
1060                let path = expect_str(args.first())?.to_string();
1061                let resolved = self.resolve_read_path(&path);
1062                // Honor read-allowlist if any. Symmetric with io.write.
1063                // The path argument is checked as-given (resolved-against-
1064                // read_root for tests); a tool granted [io] cannot escape
1065                // the configured prefix even though the effect itself is
1066                // permitted. This is the per-path scope the bench's case
1067                // #6 ("[io] granted, body reads /etc/passwd") needed.
1068                if !self.policy.allow_fs_read.is_empty()
1069                    && !self.policy.allow_fs_read.iter().any(|a| resolved.starts_with(a))
1070                {
1071                    return Err(format!("read of `{path}` outside --allow-fs-read"));
1072                }
1073                match std::fs::read_to_string(&resolved) {
1074                    Ok(s) => Ok(ok(Value::Str(s.into()))),
1075                    Err(e) => Ok(err(Value::Str(format!("{e}").into()))),
1076                }
1077            }
1078            ("io", "readline") => {
1079                use std::io::BufRead;
1080                let stdin = std::io::stdin();
1081                let mut line = String::new();
1082                match stdin.lock().read_line(&mut line) {
1083                    Ok(0) => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1084                    Ok(_) => {
1085                        if line.ends_with('\n') { line.pop(); }
1086                        if line.ends_with('\r') { line.pop(); }
1087                        Ok(Value::Variant { name: "Some".into(), args: vec![Value::Str(line.into())] })
1088                    }
1089                    Err(_) => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1090                }
1091            }
1092            ("io", "argv") => {
1093                let list: Vec<Value> = self.program_args.iter()
1094                    .map(|s| Value::Str(s.as_str().into()))
1095                    .collect();
1096                Ok(Value::List(list.into()))
1097            }
1098            ("io", "write") => {
1099                let path = expect_str(args.first())?.to_string();
1100                let contents = expect_str(args.get(1))?.to_string();
1101                // Honor write-allowlist if any.
1102                // Canonicalize both sides so macOS /tmp → /private/tmp symlinks
1103                // and other platform-specific path aliases compare correctly.
1104                if !self.policy.allow_fs_write.is_empty() {
1105                    let raw = std::env::current_dir()
1106                        .map(|cwd| cwd.join(&path))
1107                        .unwrap_or_else(|_| std::path::PathBuf::from(&path));
1108                    // canonicalize fails if the file doesn't exist yet (new writes).
1109                    // Fall back to canonicalizing the parent so macOS /tmp → /private/tmp
1110                    // symlinks still compare correctly against the allowlist.
1111                    let p = std::fs::canonicalize(&raw).unwrap_or_else(|_| {
1112                        raw.parent()
1113                            .and_then(|par| std::fs::canonicalize(par).ok())
1114                            .map(|par| par.join(raw.file_name().unwrap_or_default()))
1115                            .unwrap_or(raw)
1116                    });
1117                    let allowed = self.policy.allow_fs_write.iter().any(|a| {
1118                        let ca = std::fs::canonicalize(a).unwrap_or_else(|_| a.clone());
1119                        p.starts_with(&ca)
1120                    });
1121                    if !allowed {
1122                        return Err(format!("write to `{path}` outside --allow-fs-write"));
1123                    }
1124                }
1125                match std::fs::write(&path, contents) {
1126                    Ok(_) => Ok(ok(Value::Unit)),
1127                    Err(e) => Ok(err(Value::Str(format!("{e}").into()))),
1128                }
1129            }
1130            ("time", "now") => {
1131                // LEX_TEST_NOW (Unix seconds) pins for deterministic tests.
1132                if let Ok(s) = std::env::var("LEX_TEST_NOW") {
1133                    if let Ok(secs) = s.trim().parse::<i64>() {
1134                        return Ok(Value::Int(secs));
1135                    }
1136                }
1137                let secs = SystemTime::now().duration_since(UNIX_EPOCH)
1138                    .map_err(|e| format!("time: {e}"))?.as_secs();
1139                Ok(Value::Int(secs as i64))
1140            }
1141            ("time", "now_ms") => {
1142                // Unix epoch in milliseconds (#378). `LEX_TEST_NOW` is
1143                // documented in seconds, so we lift it to ms by *1000
1144                // to keep the pinning story uniform across `time.now`
1145                // and `time.now_ms`.
1146                if let Ok(s) = std::env::var("LEX_TEST_NOW") {
1147                    if let Ok(secs) = s.trim().parse::<i64>() {
1148                        return Ok(Value::Int(secs.saturating_mul(1000)));
1149                    }
1150                }
1151                let ms = SystemTime::now().duration_since(UNIX_EPOCH)
1152                    .map_err(|e| format!("time: {e}"))?.as_millis();
1153                Ok(Value::Int(ms as i64))
1154            }
1155            ("time", "now_str") => {
1156                // ISO-8601 / RFC 3339 in UTC (#378). Format mirrors
1157                // `chrono::Utc::now().to_rfc3339()` already used
1158                // elsewhere in the handler.
1159                if let Ok(s) = std::env::var("LEX_TEST_NOW") {
1160                    if let Ok(secs) = s.trim().parse::<i64>() {
1161                        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)
1162                            .unwrap_or_else(chrono::Utc::now);
1163                        return Ok(Value::Str(dt.to_rfc3339().into()));
1164                    }
1165                }
1166                Ok(Value::Str(chrono::Utc::now().to_rfc3339().into()))
1167            }
1168            ("time", "mono_ns") => {
1169                // Monotonic clock relative to process start. Cached
1170                // `Instant::now()` anchor so successive `mono_ns`
1171                // calls return strictly non-decreasing values without
1172                // depending on the wall clock. Not affected by
1173                // `LEX_TEST_NOW` — pinning a monotonic clock would
1174                // defeat its purpose; tests needing a fake monotonic
1175                // clock should swap in their own `EffectHandler`.
1176                static MONO_START: OnceLock<std::time::Instant> = OnceLock::new();
1177                let start = MONO_START.get_or_init(std::time::Instant::now);
1178                let dur = std::time::Instant::now().duration_since(*start);
1179                Ok(Value::Int(dur.as_nanos() as i64))
1180            }
1181            ("time", "sleep_ms") => {
1182                // Block the current thread for `n` ms (#226). Used
1183                // by `flow.retry_with_backoff`'s exponential delay.
1184                // Negative or zero is a no-op. Bounded at 60s in the
1185                // runtime to avoid pathological agent-emitted loops
1186                // wedging the host — anything legitimate beyond
1187                // that should use process-level scheduling, not a
1188                // blocking sleep.
1189                let n = expect_int(args.first())?;
1190                if n > 0 {
1191                    let ms = (n as u64).min(60_000);
1192                    std::thread::sleep(std::time::Duration::from_millis(ms));
1193                }
1194                Ok(Value::Unit)
1195            }
1196            ("time", "sleep") => {
1197                // Duration-typed sleep (#445). Duration values are
1198                // backed by `Int` nanoseconds at runtime (see the
1199                // `datetime.duration_*` constructors). Same 60s cap
1200                // as `sleep_ms` — kept consistent so all blocking
1201                // sleeps share one ceiling.
1202                let nanos = expect_int(args.first())?;
1203                if nanos > 0 {
1204                    let bounded_nanos = (nanos as u64).min(60_000 * 1_000_000);
1205                    std::thread::sleep(std::time::Duration::from_nanos(bounded_nanos));
1206                }
1207                Ok(Value::Unit)
1208            }
1209            ("rand", "int_in") => {
1210                // Honest uniform draw in [lo, hi] inclusive from the OS RNG
1211                // (#677), replacing the old deterministic midpoint stub.
1212                // Same entropy source as `crypto.random`; gated `[random]`.
1213                let lo = expect_int(args.first())?;
1214                let hi = expect_int(args.get(1))?;
1215                if hi < lo {
1216                    return Err(format!("rand.int_in: empty range [{lo}, {hi}]"));
1217                }
1218                use rand::{rngs::SysRng, TryRng};
1219                // span fits in u128 even for the full i64 range; bias from
1220                // the modulo over a 128-bit draw is < 2^-64 (negligible).
1221                let span = (hi as i128 - lo as i128 + 1) as u128;
1222                let mut buf = [0u8; 16];
1223                SysRng.try_fill_bytes(&mut buf)
1224                    .map_err(|e| format!("rand.int_in: OS RNG: {e}"))?;
1225                let draw = (u128::from_le_bytes(buf) % span) as i128;
1226                Ok(Value::Int((lo as i128 + draw) as i64))
1227            }
1228            // `env.get` returns `Option[Str]` — `None` for unset vars.
1229            // Per-var scoping (`[env(NAME)]`) arrives with #207's
1230            // per-capability effect parameterization; today the flat
1231            // `[env]` grants access to the entire process environment.
1232            ("env", "get") => {
1233                let name = expect_str(args.first())?;
1234                Ok(match std::env::var(name) {
1235                    Ok(v) => Value::Variant {
1236                        name: "Some".into(),
1237                        args: vec![Value::Str(v.into())],
1238                    },
1239                    Err(_) => Value::Variant { name: "None".into(), args: Vec::new() },
1240                })
1241            }
1242            ("budget", _) => {
1243                // Budget calls are nominally tracked here; budget itself is
1244                // enforced statically in `policy::check_program`.
1245                Ok(Value::Unit)
1246            }
1247            ("net", "get") => {
1248                let url = expect_str(args.first())?.to_string();
1249                self.ensure_host_allowed(&url)?;
1250                Ok(http_request("GET", &url, None))
1251            }
1252            ("net", "post") => {
1253                let url = expect_str(args.first())?.to_string();
1254                let body = expect_str(args.get(1))?.to_string();
1255                self.ensure_host_allowed(&url)?;
1256                Ok(http_request("POST", &url, Some(&body)))
1257            }
1258            ("net", "serve") => {
1259                let port = match args.first() {
1260                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
1261                    _ => return Err("net.serve(port, handler): port must be Int 0..=65535".into()),
1262                };
1263                let handler_name = expect_str(args.get(1))?.to_string();
1264                let program = self.program.clone()
1265                    .ok_or_else(|| "net.serve requires a Program reference; use DefaultHandler::with_program".to_string())?;
1266                let policy = self.policy.clone();
1267                serve_http(port, handler_name, program, policy, None, ServeOpts::from_env())
1268            }
1269            ("net", "serve_fn") => {
1270                let port = match args.first() {
1271                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
1272                    _ => return Err("net.serve_fn(port, handler): port must be Int 0..=65535".into()),
1273                };
1274                let closure = match args.into_iter().nth(1) {
1275                    Some(c @ Value::Closure { .. }) => c,
1276                    _ => return Err("net.serve_fn(port, handler): handler must be a closure".into()),
1277                };
1278                let program = self.program.clone()
1279                    .ok_or_else(|| "net.serve_fn requires a Program reference; use DefaultHandler::with_program".to_string())?;
1280                let policy = self.policy.clone();
1281                serve_http_fn(port, closure, program, policy, ServeOpts::from_env())
1282            }
1283            ("net", "serve_routed") => {
1284                let port = match args.first() {
1285                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
1286                    _ => return Err("net.serve_routed(port, routes, fallback): port must be Int 0..=65535".into()),
1287                };
1288                let routes_val = args.get(1).cloned()
1289                    .ok_or_else(|| "net.serve_routed(port, routes, fallback): missing routes".to_string())?;
1290                let fallback = match args.into_iter().nth(2) {
1291                    Some(c @ Value::Closure { .. }) => c,
1292                    _ => return Err("net.serve_routed(port, routes, fallback): fallback must be a closure".into()),
1293                };
1294                let routes = decode_routes_arg(routes_val)?;
1295                let program = self.program.clone()
1296                    .ok_or_else(|| "net.serve_routed requires a Program reference; use DefaultHandler::with_program".to_string())?;
1297                let policy = self.policy.clone();
1298                serve_http_routed(port, routes, fallback, program, policy, ServeOpts::from_env())
1299            }
1300            ("net", "serve_with") => {
1301                // serve_with(port, handler_name, opts)
1302                let port = match args.first() {
1303                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
1304                    _ => return Err("net.serve_with(port, handler, opts): port must be Int 0..=65535".into()),
1305                };
1306                let handler_name = expect_str(args.get(1))?.to_string();
1307                let opts = decode_serve_opts(args.get(2)
1308                    .ok_or_else(|| "net.serve_with(port, handler, opts): missing opts".to_string())?)?;
1309                let program = self.program.clone()
1310                    .ok_or_else(|| "net.serve_with requires a Program reference; use DefaultHandler::with_program".to_string())?;
1311                let policy = self.policy.clone();
1312                serve_http(port, handler_name, program, policy, None, opts)
1313            }
1314            ("net", "serve_fn_with") => {
1315                // serve_fn_with(port, handler_closure, opts)
1316                let port = match args.first() {
1317                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
1318                    _ => return Err("net.serve_fn_with(port, handler, opts): port must be Int 0..=65535".into()),
1319                };
1320                let opts = decode_serve_opts(args.get(2)
1321                    .ok_or_else(|| "net.serve_fn_with(port, handler, opts): missing opts".to_string())?)?;
1322                let closure = match args.into_iter().nth(1) {
1323                    Some(c @ Value::Closure { .. }) => c,
1324                    _ => return Err("net.serve_fn_with(port, handler, opts): handler must be a closure".into()),
1325                };
1326                let program = self.program.clone()
1327                    .ok_or_else(|| "net.serve_fn_with requires a Program reference; use DefaultHandler::with_program".to_string())?;
1328                let policy = self.policy.clone();
1329                serve_http_fn(port, closure, program, policy, opts)
1330            }
1331            ("net", "serve_routed_with") => {
1332                // serve_routed_with(port, routes, fallback, opts)
1333                let port = match args.first() {
1334                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
1335                    _ => return Err("net.serve_routed_with(port, routes, fallback, opts): port must be Int 0..=65535".into()),
1336                };
1337                let routes_val = args.get(1).cloned()
1338                    .ok_or_else(|| "net.serve_routed_with(port, routes, fallback, opts): missing routes".to_string())?;
1339                let opts = decode_serve_opts(args.get(3)
1340                    .ok_or_else(|| "net.serve_routed_with(port, routes, fallback, opts): missing opts".to_string())?)?;
1341                let fallback = match args.into_iter().nth(2) {
1342                    Some(c @ Value::Closure { .. }) => c,
1343                    _ => return Err("net.serve_routed_with(port, routes, fallback, opts): fallback must be a closure".into()),
1344                };
1345                let routes = decode_routes_arg(routes_val)?;
1346                let program = self.program.clone()
1347                    .ok_or_else(|| "net.serve_routed_with requires a Program reference; use DefaultHandler::with_program".to_string())?;
1348                let policy = self.policy.clone();
1349                serve_http_routed(port, routes, fallback, program, policy, opts)
1350            }
1351            ("net", "serve_quic") => self.dispatch_serve_quic_named(args),
1352            ("net", "serve_quic_fn") => self.dispatch_serve_quic_fn(args),
1353            ("net", "serve_quic_routed") => self.dispatch_serve_quic_routed(args),
1354            ("net", "serve_tls") => {
1355                let port = match args.first() {
1356                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
1357                    _ => return Err("net.serve_tls(port, cert, key, handler): port must be Int 0..=65535".into()),
1358                };
1359                let cert_path = expect_str(args.get(1))?.to_string();
1360                let key_path = expect_str(args.get(2))?.to_string();
1361                let handler_name = expect_str(args.get(3))?.to_string();
1362                let program = self.program.clone()
1363                    .ok_or_else(|| "net.serve_tls requires a Program reference".to_string())?;
1364                let policy = self.policy.clone();
1365                let cert = std::fs::read(&cert_path)
1366                    .map_err(|e| format!("net.serve_tls: read cert {cert_path}: {e}"))?;
1367                let key = std::fs::read(&key_path)
1368                    .map_err(|e| format!("net.serve_tls: read key {key_path}: {e}"))?;
1369                serve_http(port, handler_name, program, policy, Some(TlsConfig { cert, key }), ServeOpts::from_env())
1370            }
1371            ("net", "serve_ws") => {
1372                let port = match args.first() {
1373                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
1374                    _ => return Err("net.serve_ws(port, on_message): port must be Int 0..=65535".into()),
1375                };
1376                let handler_name = expect_str(args.get(1))?.to_string();
1377                let program = self.program.clone()
1378                    .ok_or_else(|| "net.serve_ws requires a Program reference".to_string())?;
1379                let policy = self.policy.clone();
1380                let registry = Arc::new(crate::ws::ChatRegistry::default());
1381                crate::ws::serve_ws(port, handler_name, program, policy, registry)
1382            }
1383            ("net", "serve_ws_fn") => {
1384                let port = match args.first() {
1385                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
1386                    _ => return Err("net.serve_ws_fn(port, subprotocol, handler): port must be Int 0..=65535".into()),
1387                };
1388                let subprotocol = expect_str(args.get(1))?.to_string();
1389                let closure = match args.into_iter().nth(2) {
1390                    Some(c @ Value::Closure { .. }) => c,
1391                    _ => return Err("net.serve_ws_fn(port, subprotocol, handler): handler must be a closure".into()),
1392                };
1393                let program = self.program.clone()
1394                    .ok_or_else(|| "net.serve_ws_fn requires a Program reference; use DefaultHandler::with_program".to_string())?;
1395                let policy = self.policy.clone();
1396                let registry = Arc::new(crate::ws::ChatRegistry::default());
1397                crate::ws::serve_ws_fn(port, subprotocol, closure, program, policy, registry)
1398            }
1399            ("net", "serve_ws_fn_auth") => {
1400                // serve_ws_fn_auth(port, subprotocol, auth, on_message)
1401                let port = match args.first() {
1402                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
1403                    _ => return Err("net.serve_ws_fn_auth(port, subprotocol, auth, on_message): port must be Int 0..=65535".into()),
1404                };
1405                let subprotocol = expect_str(args.get(1))?.to_string();
1406                let mut it = args.into_iter().skip(2);
1407                let auth_closure = match it.next() {
1408                    Some(c @ Value::Closure { .. }) => c,
1409                    _ => return Err("net.serve_ws_fn_auth(port, subprotocol, auth, on_message): auth must be a closure".into()),
1410                };
1411                let handler_closure = match it.next() {
1412                    Some(c @ Value::Closure { .. }) => c,
1413                    _ => return Err("net.serve_ws_fn_auth(port, subprotocol, auth, on_message): on_message must be a closure".into()),
1414                };
1415                let program = self.program.clone()
1416                    .ok_or_else(|| "net.serve_ws_fn_auth requires a Program reference; use DefaultHandler::with_program".to_string())?;
1417                let policy = self.policy.clone();
1418                let registry = Arc::new(crate::ws::ChatRegistry::default());
1419                crate::ws::serve_ws_fn_auth(
1420                    port, subprotocol, auth_closure, handler_closure,
1421                    program, policy, registry,
1422                )
1423            }
1424            ("net", "serve_ws_fn_actor") => {
1425                // serve_ws_fn_actor(port, subprotocol, name_of, on_message)
1426                let port = match args.first() {
1427                    Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
1428                    _ => return Err("net.serve_ws_fn_actor(port, subprotocol, name_of, on_message): port must be Int 0..=65535".into()),
1429                };
1430                let subprotocol = expect_str(args.get(1))?.to_string();
1431                let mut it = args.into_iter().skip(2);
1432                let name_of_closure = match it.next() {
1433                    Some(c @ Value::Closure { .. }) => c,
1434                    _ => return Err("net.serve_ws_fn_actor(port, subprotocol, name_of, on_message): name_of must be a closure".into()),
1435                };
1436                let on_message_closure = match it.next() {
1437                    Some(c @ Value::Closure { .. }) => c,
1438                    _ => return Err("net.serve_ws_fn_actor(port, subprotocol, name_of, on_message): on_message must be a closure".into()),
1439                };
1440                let program = self.program.clone()
1441                    .ok_or_else(|| "net.serve_ws_fn_actor requires a Program reference; use DefaultHandler::with_program".to_string())?;
1442                let policy = self.policy.clone();
1443                let registry = Arc::new(crate::ws::ChatRegistry::default());
1444                crate::ws::serve_ws_fn_actor(
1445                    port, subprotocol, name_of_closure, on_message_closure,
1446                    program, policy, registry,
1447                )
1448            }
1449            ("net", "dial_ws") => {
1450                // dial_ws(url, subprotocol, on_open, on_message)
1451                let url = expect_str(args.first())?.to_string();
1452                let subprotocol = expect_str(args.get(1))?.to_string();
1453                let on_open = match args.get(2).cloned() {
1454                    Some(c @ Value::Closure { .. }) => c,
1455                    _ => return Err(
1456                        "net.dial_ws(url, subprotocol, on_open, on_message): on_open must be a closure".into(),
1457                    ),
1458                };
1459                let on_message = match args.into_iter().nth(3) {
1460                    Some(c @ Value::Closure { .. }) => c,
1461                    _ => return Err(
1462                        "net.dial_ws(url, subprotocol, on_open, on_message): on_message must be a closure".into(),
1463                    ),
1464                };
1465                let program = self.program.clone().ok_or_else(|| {
1466                    "net.dial_ws requires a Program reference; use DefaultHandler::with_program".to_string()
1467                })?;
1468                let policy = self.policy.clone();
1469                crate::ws::dial_ws(url, subprotocol, on_open, on_message, program, policy)
1470            }
1471            ("net", "dial_ws_actor") => {
1472                // dial_ws_actor(url, subprotocol, name, on_open, on_message)
1473                let url = expect_str(args.first())?.to_string();
1474                let subprotocol = expect_str(args.get(1))?.to_string();
1475                let name = expect_str(args.get(2))?.to_string();
1476                let on_open = match args.get(3).cloned() {
1477                    Some(c @ Value::Closure { .. }) => c,
1478                    _ => return Err(
1479                        "net.dial_ws_actor(url, subprotocol, name, on_open, on_message): on_open must be a closure".into(),
1480                    ),
1481                };
1482                let on_message = match args.into_iter().nth(4) {
1483                    Some(c @ Value::Closure { .. }) => c,
1484                    _ => return Err(
1485                        "net.dial_ws_actor(url, subprotocol, name, on_open, on_message): on_message must be a closure".into(),
1486                    ),
1487                };
1488                let program = self.program.clone().ok_or_else(|| {
1489                    "net.dial_ws_actor requires a Program reference; use DefaultHandler::with_program".to_string()
1490                })?;
1491                let policy = self.policy.clone();
1492                crate::ws::dial_ws_actor(url, subprotocol, name, on_open, on_message, program, policy)
1493            }
1494            ("chat", "broadcast") => {
1495                let registry = self.chat_registry.as_ref()
1496                    .ok_or_else(|| "chat.broadcast called outside a net.serve_ws handler".to_string())?;
1497                let room = expect_str(args.first())?;
1498                let body = expect_str(args.get(1))?;
1499                crate::ws::chat_broadcast(registry, room, body);
1500                Ok(Value::Unit)
1501            }
1502            ("chat", "send") => {
1503                let registry = self.chat_registry.as_ref()
1504                    .ok_or_else(|| "chat.send called outside a net.serve_ws handler".to_string())?;
1505                let conn_id = match args.first() {
1506                    Some(Value::Int(n)) if *n >= 0 => *n as u64,
1507                    _ => return Err("chat.send: conn_id must be non-negative Int".into()),
1508                };
1509                let body = expect_str(args.get(1))?;
1510                Ok(Value::Bool(crate::ws::chat_send(registry, conn_id, body)))
1511            }
1512            ("kv", "open") => {
1513                let path = expect_str(args.first())?.to_string();
1514                // Honor write-allowlist: opening a Kv writes its
1515                // backing files at `path`, so the same scoping that
1516                // applies to `io.write` applies here.
1517                if !self.policy.allow_fs_write.is_empty() {
1518                    let p = std::path::Path::new(&path);
1519                    if !self.policy.allow_fs_write.iter().any(|a| p.starts_with(a)) {
1520                        return Ok(err(Value::Str(format!(
1521                            "kv.open: `{path}` outside --allow-fs-write").into())));
1522                    }
1523                }
1524                match sled::open(&path) {
1525                    Ok(db) => {
1526                        let handle = next_kv_handle();
1527                        kv_registry().lock().unwrap().insert(handle, db);
1528                        Ok(ok(Value::Int(handle as i64)))
1529                    }
1530                    Err(e) => Ok(err(Value::Str(format!("kv.open: {e}").into()))),
1531                }
1532            }
1533            ("kv", "close") => {
1534                let h = expect_kv_handle(args.first())?;
1535                kv_registry().lock().unwrap().remove(h);
1536                Ok(Value::Unit)
1537            }
1538            ("kv", "get") => {
1539                let h = expect_kv_handle(args.first())?;
1540                let key = expect_str(args.get(1))?;
1541                let mut reg = kv_registry().lock().unwrap();
1542                let db = reg.touch_get(h).ok_or_else(|| "kv.get: closed or unknown Kv handle".to_string())?;
1543                match db.get(key.as_bytes()) {
1544                    Ok(Some(ivec)) => Ok(some(Value::Bytes(ivec.to_vec()))),
1545                    Ok(None) => Ok(none()),
1546                    Err(e) => Err(format!("kv.get: {e}")),
1547                }
1548            }
1549            ("kv", "put") => {
1550                let h = expect_kv_handle(args.first())?;
1551                let key = expect_str(args.get(1))?.to_string();
1552                let val = expect_bytes(args.get(2))?.clone();
1553                let mut reg = kv_registry().lock().unwrap();
1554                let db = reg.touch_get(h).ok_or_else(|| "kv.put: closed or unknown Kv handle".to_string())?;
1555                match db.insert(key.as_bytes(), val) {
1556                    Ok(_) => Ok(ok(Value::Unit)),
1557                    Err(e) => Ok(err(Value::Str(format!("kv.put: {e}").into()))),
1558                }
1559            }
1560            ("kv", "delete") => {
1561                let h = expect_kv_handle(args.first())?;
1562                let key = expect_str(args.get(1))?;
1563                let mut reg = kv_registry().lock().unwrap();
1564                let db = reg.touch_get(h).ok_or_else(|| "kv.delete: closed or unknown Kv handle".to_string())?;
1565                match db.remove(key.as_bytes()) {
1566                    Ok(_) => Ok(ok(Value::Unit)),
1567                    Err(e) => Ok(err(Value::Str(format!("kv.delete: {e}").into()))),
1568                }
1569            }
1570            ("kv", "contains") => {
1571                let h = expect_kv_handle(args.first())?;
1572                let key = expect_str(args.get(1))?;
1573                let mut reg = kv_registry().lock().unwrap();
1574                let db = reg.touch_get(h).ok_or_else(|| "kv.contains: closed or unknown Kv handle".to_string())?;
1575                match db.contains_key(key.as_bytes()) {
1576                    Ok(present) => Ok(Value::Bool(present)),
1577                    Err(e) => Err(format!("kv.contains: {e}")),
1578                }
1579            }
1580            ("kv", "list_prefix") => {
1581                let h = expect_kv_handle(args.first())?;
1582                let prefix = expect_str(args.get(1))?;
1583                let mut reg = kv_registry().lock().unwrap();
1584                let db = reg.touch_get(h).ok_or_else(|| "kv.list_prefix: closed or unknown Kv handle".to_string())?;
1585                let mut keys: Vec<Value> = Vec::new();
1586                for kv in db.scan_prefix(prefix.as_bytes()) {
1587                    let (k, _) = kv.map_err(|e| format!("kv.list_prefix: {e}"))?;
1588                    let s = String::from_utf8_lossy(&k).to_string();
1589                    keys.push(Value::Str(s.into()));
1590                }
1591                Ok(Value::List(keys.into()))
1592            }
1593            // ── std.vcs: content-addressed blob store (#5) ──
1594            // Backed by lex-store's blob CAS (Store::put_blob/get_blob/
1595            // set_blob_ref/get_blob_ref). Effect `vcs` is gated by the generic
1596            // ensure_kind_allowed(kind) above. put_blob's sha ==
1597            // crypto.sha256_str(content), so vcs blobs and loom's SQLite
1598            // artifacts share ids. We depend on lex-store with the `trace`
1599            // feature off to avoid a lex-store → lex-trace → lex-runtime cycle.
1600            ("vcs", "put_blob") => {
1601                let content = expect_str(args.first())?.to_string();
1602                match lex_store::Store::open(vcs_store_root())
1603                    .and_then(|s| s.put_blob(&content)) {
1604                    Ok(sha) => Ok(ok(Value::Str(sha.into()))),
1605                    Err(e)  => Ok(err(Value::Str(format!("vcs.put_blob: {e}").into()))),
1606                }
1607            }
1608            ("vcs", "get_blob") => {
1609                let sha = expect_str(args.first())?.to_string();
1610                match lex_store::Store::open(vcs_store_root())
1611                    .and_then(|s| s.get_blob(&sha)) {
1612                    Ok(content) => Ok(ok(Value::Str(content.into()))),
1613                    Err(e)      => Ok(err(Value::Str(format!("vcs.get_blob: {e}").into()))),
1614                }
1615            }
1616            ("vcs", "has_blob") => {
1617                let sha = expect_str(args.first())?.to_string();
1618                let has = lex_store::Store::open(vcs_store_root())
1619                    .map(|s| s.has_blob(&sha)).unwrap_or(false);
1620                Ok(Value::Bool(has))
1621            }
1622            ("vcs", "ref_set") => {
1623                let ns  = expect_str(args.first())?.to_string();
1624                let key = expect_str(args.get(1))?.to_string();
1625                let sha = expect_str(args.get(2))?.to_string();
1626                match lex_store::Store::open(vcs_store_root())
1627                    .and_then(|s| s.set_blob_ref(&ns, &key, &sha)) {
1628                    Ok(())  => Ok(ok(Value::Unit)),
1629                    Err(e)  => Ok(err(Value::Str(format!("vcs.ref_set: {e}").into()))),
1630                }
1631            }
1632            ("vcs", "ref_get") => {
1633                let ns  = expect_str(args.first())?.to_string();
1634                let key = expect_str(args.get(1))?.to_string();
1635                match lex_store::Store::open(vcs_store_root())
1636                    .and_then(|s| s.get_blob_ref(&ns, &key)) {
1637                    Ok(sha) => Ok(ok(Value::Str(sha.into()))),
1638                    Err(e)  => Ok(err(Value::Str(format!("vcs.ref_get: {e}").into()))),
1639                }
1640            }
1641            ("sql", "open") => {
1642                let path = expect_str(args.first())?.to_string();
1643                if path.starts_with("postgres://") || path.starts_with("postgresql://") {
1644                    // Postgres: connect via sync driver; no fs-write policy applies.
1645                    match postgres::Client::connect(&path, postgres::NoTls) {
1646                        Ok(client) => {
1647                            let handle = next_sql_handle();
1648                            sql_registry().lock().unwrap().insert(handle, SqlConn::Postgres(client));
1649                            Ok(ok(Value::Int(handle as i64)))
1650                        }
1651                        Err(e) => Ok(err(pg_err_to_sql_error(e, "sql.open"))),
1652                    }
1653                } else {
1654                    // SQLite: same shape as `kv.open`; fs-write allowlist applies
1655                    // (in-memory paths are exempt).
1656                    if path != ":memory:" && !self.policy.allow_fs_write.is_empty() {
1657                        let p = std::path::Path::new(&path);
1658                        if !self.policy.allow_fs_write.iter().any(|a| p.starts_with(a)) {
1659                            return Ok(err(sql_error(
1660                                format!("sql.open: `{path}` outside --allow-fs-write"),
1661                                None, None,
1662                            )));
1663                        }
1664                    }
1665                    match rusqlite::Connection::open(&path) {
1666                        Ok(conn) => {
1667                            let handle = next_sql_handle();
1668                            sql_registry().lock().unwrap().insert(handle, SqlConn::Sqlite(conn));
1669                            Ok(ok(Value::Int(handle as i64)))
1670                        }
1671                        Err(e) => Ok(err(sqlite_err_to_sql_error(e, "sql.open"))),
1672                    }
1673                }
1674            }
1675            ("sql", "close") => {
1676                let h = expect_sql_handle(args.first())?;
1677                sql_registry().lock().unwrap().remove(h);
1678                Ok(Value::Unit)
1679            }
1680            ("sql", "exec") => {
1681                let h = expect_sql_handle(args.first())?;
1682                let stmt = expect_str(args.get(1))?.to_string();
1683                let params = expect_sql_params(args.get(2))?;
1684                let arc = sql_registry().lock().unwrap()
1685                    .touch_get(h)
1686                    .ok_or_else(|| "sql.exec: closed or unknown Db handle".to_string())?;
1687                let mut conn = arc.lock().unwrap();
1688                match &mut *conn {
1689                    SqlConn::Sqlite(c) => {
1690                        let bound = sqlite_params(&params);
1691                        let bind: Vec<&dyn rusqlite::ToSql> =
1692                            bound.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
1693                        match c.execute(&stmt, rusqlite::params_from_iter(bind.iter())) {
1694                            Ok(n)  => Ok(ok(Value::Int(n as i64))),
1695                            Err(e) => Ok(err(sqlite_err_to_sql_error(e, "sql.exec"))),
1696                        }
1697                    }
1698                    SqlConn::Postgres(c) => {
1699                        let pg = pg_param_refs(&params);
1700                        let refs: Vec<&(dyn postgres::types::ToSql + Sync)> =
1701                            pg.iter().map(|b| b.as_ref()).collect();
1702                        match c.execute(pg_rewrite_placeholders(stmt.as_str()).as_str(), &refs) {
1703                            Ok(n)  => Ok(ok(Value::Int(n as i64))),
1704                            Err(e) => Ok(err(pg_err_to_sql_error(e, "sql.exec"))),
1705                        }
1706                    }
1707                }
1708            }
1709            ("sql", "query") => {
1710                let h = expect_sql_handle(args.first())?;
1711                let stmt_str = expect_str(args.get(1))?.to_string();
1712                let params = expect_sql_params(args.get(2))?;
1713                let arc = sql_registry().lock().unwrap()
1714                    .touch_get(h)
1715                    .ok_or_else(|| "sql.query: closed or unknown Db handle".to_string())?;
1716                let mut conn = arc.lock().unwrap();
1717                Ok(match &mut *conn {
1718                    SqlConn::Sqlite(c)   => sql_run_query_sqlite(c, &stmt_str, &params),
1719                    SqlConn::Postgres(c) => sql_run_query_pg(c, &stmt_str, &params),
1720                })
1721            }
1722            // Streaming cursor (#379). Allocates an mpsc-backed cursor
1723            // handle, spawns a producer thread to ship rows one at a
1724            // time, and returns `__IterCursor(handle)` wrapped in `Ok`.
1725            // `iter.next` bytecode dispatches the variant tag and
1726            // effect-calls `sql.cursor_next` (below) to advance.
1727            ("sql", "query_iter") => {
1728                let h = expect_sql_handle(args.first())?;
1729                let stmt_str = expect_str(args.get(1))?.to_string();
1730                let params = expect_sql_params(args.get(2))?;
1731                let arc = sql_registry().lock().unwrap()
1732                    .touch_get(h)
1733                    .ok_or_else(|| "sql.query_iter: closed or unknown Db handle".to_string())?;
1734
1735                // Dispatch producer on the connection kind without
1736                // holding the SqlRegistry lock — the producer thread
1737                // owns its own clone of the connection Arc.
1738                let (sender, receiver) = std::sync::mpsc::sync_channel::<Result<Value, String>>(
1739                    CURSOR_CHANNEL_CAPACITY,
1740                );
1741                let cursor_h = next_cursor_handle();
1742                cursor_registry().lock().unwrap().insert(cursor_h, receiver);
1743
1744                let arc_for_thread = Arc::clone(&arc);
1745                // Decide which producer to spawn based on the
1746                // connection's variant. We can briefly peek at the
1747                // variant here without holding the lock for the
1748                // producer's lifetime — the producer locks again
1749                // inside its thread function.
1750                let is_sqlite = matches!(*arc.lock().unwrap(), SqlConn::Sqlite(_));
1751                std::thread::spawn(move || {
1752                    if is_sqlite {
1753                        sqlite_cursor_producer(arc_for_thread, stmt_str, params, sender);
1754                    } else {
1755                        pg_cursor_producer(arc_for_thread, stmt_str, params, sender);
1756                    }
1757                });
1758
1759                Ok(ok(Value::Variant {
1760                    name: "__IterCursor".into(),
1761                    args: vec![Value::Int(cursor_h as i64)],
1762                }))
1763            }
1764            // Pull one row from the producer; called from
1765            // `iter.next`'s `__IterCursor` dispatch branch. Returns
1766            // a Lex `Option[Row]`: `Some(row)` while the producer
1767            // has more, `None` once the channel closes (producer
1768            // done, errored, or cursor evicted from the registry).
1769            ("sql", "cursor_next") => {
1770                let h = match args.first() {
1771                    Some(Value::Int(n)) if *n >= 0 => *n as u64,
1772                    _ => return Err("sql.cursor_next: expected cursor handle (Int)".into()),
1773                };
1774                let rx_arc = match cursor_registry().lock().unwrap().touch_get(h) {
1775                    Some(a) => a,
1776                    None => return Ok(Value::Variant { name: "None".into(), args: vec![] }),
1777                };
1778                // Lock the receiver itself (separate from the global
1779                // registry lock) and block on `recv()`. The producer
1780                // is on a different thread, so this can sleep without
1781                // contention beyond the per-cursor mutex.
1782                let recv_result = {
1783                    let rx = match rx_arc.lock() {
1784                        Ok(g) => g,
1785                        Err(p) => p.into_inner(),
1786                    };
1787                    rx.recv()
1788                };
1789                match recv_result {
1790                    Ok(Ok(row)) => Ok(Value::Variant {
1791                        name: "Some".into(),
1792                        args: vec![row],
1793                    }),
1794                    Ok(Err(_)) | Err(_) => {
1795                        // Channel closed (producer done) or row error
1796                        // — drop the registry entry and signal None
1797                        // so callers stop polling.
1798                        cursor_registry().lock().unwrap().remove(h);
1799                        Ok(Value::Variant { name: "None".into(), args: vec![] })
1800                    }
1801                }
1802            }
1803            // Transactions: begin issues BEGIN SQL on the connection;
1804            // commit/rollback issue COMMIT/ROLLBACK. SqlTx reuses the
1805            // same Int handle as Db — the type system enforces correct
1806            // usage; the runtime treats both as the same registry key.
1807            ("sql", "begin") => {
1808                let h = expect_sql_handle(args.first())?;
1809                let arc = sql_registry().lock().unwrap()
1810                    .touch_get(h)
1811                    .ok_or_else(|| "sql.begin: closed or unknown Db handle".to_string())?;
1812                let mut conn = arc.lock().unwrap();
1813                match &mut *conn {
1814                    SqlConn::Sqlite(c) => match c.execute_batch("BEGIN") {
1815                        Ok(()) => Ok(ok(Value::Int(h as i64))),
1816                        Err(e) => Ok(err(sqlite_err_to_sql_error(e, "sql.begin"))),
1817                    },
1818                    SqlConn::Postgres(c) => match c.batch_execute("BEGIN") {
1819                        Ok(()) => Ok(ok(Value::Int(h as i64))),
1820                        Err(e) => Ok(err(pg_err_to_sql_error(e, "sql.begin"))),
1821                    },
1822                }
1823            }
1824            ("sql", "commit") => {
1825                let h = expect_sql_handle(args.first())?;
1826                let arc = sql_registry().lock().unwrap()
1827                    .touch_get(h)
1828                    .ok_or_else(|| "sql.commit: closed or unknown SqlTx handle".to_string())?;
1829                let mut conn = arc.lock().unwrap();
1830                match &mut *conn {
1831                    SqlConn::Sqlite(c) => match c.execute_batch("COMMIT") {
1832                        Ok(()) => Ok(ok(Value::Unit)),
1833                        Err(e) => Ok(err(sqlite_err_to_sql_error(e, "sql.commit"))),
1834                    },
1835                    SqlConn::Postgres(c) => match c.batch_execute("COMMIT") {
1836                        Ok(()) => Ok(ok(Value::Unit)),
1837                        Err(e) => Ok(err(pg_err_to_sql_error(e, "sql.commit"))),
1838                    },
1839                }
1840            }
1841            ("sql", "rollback") => {
1842                let h = expect_sql_handle(args.first())?;
1843                let arc = sql_registry().lock().unwrap()
1844                    .touch_get(h)
1845                    .ok_or_else(|| "sql.rollback: closed or unknown SqlTx handle".to_string())?;
1846                let mut conn = arc.lock().unwrap();
1847                match &mut *conn {
1848                    SqlConn::Sqlite(c) => match c.execute_batch("ROLLBACK") {
1849                        Ok(()) => Ok(ok(Value::Unit)),
1850                        Err(e) => Ok(err(sqlite_err_to_sql_error(e, "sql.rollback"))),
1851                    },
1852                    SqlConn::Postgres(c) => match c.batch_execute("ROLLBACK") {
1853                        Ok(()) => Ok(ok(Value::Unit)),
1854                        Err(e) => Ok(err(pg_err_to_sql_error(e, "sql.rollback"))),
1855                    },
1856                }
1857            }
1858            ("sql", "exec_tx") => {
1859                let h = expect_sql_handle(args.first())?;
1860                let stmt = expect_str(args.get(1))?.to_string();
1861                let params = expect_sql_params(args.get(2))?;
1862                let arc = sql_registry().lock().unwrap()
1863                    .touch_get(h)
1864                    .ok_or_else(|| "sql.exec_tx: closed or unknown SqlTx handle".to_string())?;
1865                let mut conn = arc.lock().unwrap();
1866                match &mut *conn {
1867                    SqlConn::Sqlite(c) => {
1868                        let bound = sqlite_params(&params);
1869                        let bind: Vec<&dyn rusqlite::ToSql> =
1870                            bound.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
1871                        match c.execute(&stmt, rusqlite::params_from_iter(bind.iter())) {
1872                            Ok(n)  => Ok(ok(Value::Int(n as i64))),
1873                            Err(e) => Ok(err(sqlite_err_to_sql_error(e, "sql.exec_tx"))),
1874                        }
1875                    }
1876                    SqlConn::Postgres(c) => {
1877                        let pg = pg_param_refs(&params);
1878                        let refs: Vec<&(dyn postgres::types::ToSql + Sync)> =
1879                            pg.iter().map(|b| b.as_ref()).collect();
1880                        match c.execute(pg_rewrite_placeholders(stmt.as_str()).as_str(), &refs) {
1881                            Ok(n)  => Ok(ok(Value::Int(n as i64))),
1882                            Err(e) => Ok(err(pg_err_to_sql_error(e, "sql.exec_tx"))),
1883                        }
1884                    }
1885                }
1886            }
1887            ("sql", "query_tx") => {
1888                let h = expect_sql_handle(args.first())?;
1889                let stmt_str = expect_str(args.get(1))?.to_string();
1890                let params = expect_sql_params(args.get(2))?;
1891                let arc = sql_registry().lock().unwrap()
1892                    .touch_get(h)
1893                    .ok_or_else(|| "sql.query_tx: closed or unknown SqlTx handle".to_string())?;
1894                let mut conn = arc.lock().unwrap();
1895                Ok(match &mut *conn {
1896                    SqlConn::Sqlite(c)   => sql_run_query_sqlite(c, &stmt_str, &params),
1897                    SqlConn::Postgres(c) => sql_run_query_pg(c, &stmt_str, &params),
1898                })
1899            }
1900            ("sql", "get_str") => Ok(sql_get_col(&args, |v| match v {
1901                Value::Str(s) => Some(Value::Str(s.clone())),
1902                Value::Int(n) => Some(Value::Str(n.to_string().into())),
1903                _ => None,
1904            })?),
1905            ("sql", "get_int") => Ok(sql_get_col(&args, |v| match v {
1906                Value::Int(n) => Some(Value::Int(*n)),
1907                Value::Float(f) => Some(Value::Int(*f as i64)),
1908                _ => None,
1909            })?),
1910            ("sql", "get_float") => Ok(sql_get_col(&args, |v| match v {
1911                Value::Float(f) => Some(Value::Float(*f)),
1912                Value::Int(n)   => Some(Value::Float(*n as f64)),
1913                _ => None,
1914            })?),
1915            ("sql", "get_bool") => Ok(sql_get_col(&args, |v| match v {
1916                Value::Bool(b)  => Some(Value::Bool(*b)),
1917                Value::Int(n)   => Some(Value::Bool(*n != 0)),
1918                _ => None,
1919            })?),
1920
1921            // ── std.redis (#533) ─────────────────────────────────────────
1922            //
1923            // ConnRedis is an opaque Int handle into the global RedisRegistry.
1924            // All ops carry [net] — Redis is a TCP service.
1925            //
1926            // subscribe/psubscribe open a *dedicated* connection so they don't
1927            // interfere with the handle's regular connection. Redis disallows
1928            // non-Pub/Sub commands on a subscribed connection.
1929            ("redis", "connect") => {
1930                let url = expect_str(args.first())?.to_string();
1931                self.ensure_host_allowed(&url)?;
1932                match redis::Client::open(url.as_str()) {
1933                    Ok(client) => match client.get_connection() {
1934                        Ok(conn) => {
1935                            let handle = next_redis_handle();
1936                            redis_registry().lock().unwrap().insert(handle, RedisEntry { url, conn });
1937                            Ok(ok(Value::Int(handle as i64)))
1938                        }
1939                        Err(e) => Ok(err(Value::Str(format!("redis.connect: {e}").into()))),
1940                    },
1941                    Err(e) => Ok(err(Value::Str(format!("redis.connect: {e}").into()))),
1942                }
1943            }
1944            ("redis", "close") => {
1945                let h = expect_redis_handle(args.first())?;
1946                redis_registry().lock().unwrap().remove(h);
1947                Ok(Value::Unit)
1948            }
1949            ("redis", "get") => {
1950                let h = expect_redis_handle(args.first())?;
1951                let key = expect_str(args.get(1))?.to_string();
1952                let mut reg = redis_registry().lock().unwrap();
1953                let entry = reg.touch_get_mut(h)
1954                    .ok_or_else(|| "redis.get: closed or unknown ConnRedis handle".to_string())?;
1955                use redis::Commands;
1956                match entry.conn.get::<_, Option<String>>(&key) {
1957                    Ok(Some(v)) => Ok(some(Value::Str(v.into()))),
1958                    Ok(None)    => Ok(none()),
1959                    Err(e)      => Err(format!("redis.get: {e}")),
1960                }
1961            }
1962            ("redis", "set") => {
1963                let h = expect_redis_handle(args.first())?;
1964                let key = expect_str(args.get(1))?.to_string();
1965                let val = expect_str(args.get(2))?.to_string();
1966                let mut reg = redis_registry().lock().unwrap();
1967                let entry = reg.touch_get_mut(h)
1968                    .ok_or_else(|| "redis.set: closed or unknown ConnRedis handle".to_string())?;
1969                use redis::Commands;
1970                entry.conn.set::<_, _, ()>(&key, &val)
1971                    .map_err(|e| format!("redis.set: {e}"))?;
1972                Ok(Value::Unit)
1973            }
1974            ("redis", "set_ex") => {
1975                let h = expect_redis_handle(args.first())?;
1976                let key = expect_str(args.get(1))?.to_string();
1977                let val = expect_str(args.get(2))?.to_string();
1978                let ttl = expect_int(args.get(3))?;
1979                let mut reg = redis_registry().lock().unwrap();
1980                let entry = reg.touch_get_mut(h)
1981                    .ok_or_else(|| "redis.set_ex: closed or unknown ConnRedis handle".to_string())?;
1982                use redis::Commands;
1983                entry.conn.set_ex::<_, _, ()>(&key, &val, ttl as u64)
1984                    .map_err(|e| format!("redis.set_ex: {e}"))?;
1985                Ok(Value::Unit)
1986            }
1987            ("redis", "del") => {
1988                let h = expect_redis_handle(args.first())?;
1989                let key = expect_str(args.get(1))?.to_string();
1990                let mut reg = redis_registry().lock().unwrap();
1991                let entry = reg.touch_get_mut(h)
1992                    .ok_or_else(|| "redis.del: closed or unknown ConnRedis handle".to_string())?;
1993                use redis::Commands;
1994                entry.conn.del::<_, ()>(&key)
1995                    .map_err(|e| format!("redis.del: {e}"))?;
1996                Ok(Value::Unit)
1997            }
1998            ("redis", "exists") => {
1999                let h = expect_redis_handle(args.first())?;
2000                let key = expect_str(args.get(1))?.to_string();
2001                let mut reg = redis_registry().lock().unwrap();
2002                let entry = reg.touch_get_mut(h)
2003                    .ok_or_else(|| "redis.exists: closed or unknown ConnRedis handle".to_string())?;
2004                use redis::Commands;
2005                let present: bool = entry.conn.exists(&key)
2006                    .map_err(|e| format!("redis.exists: {e}"))?;
2007                Ok(Value::Bool(present))
2008            }
2009            ("redis", "expire") => {
2010                let h = expect_redis_handle(args.first())?;
2011                let key = expect_str(args.get(1))?.to_string();
2012                let ttl = expect_int(args.get(2))?;
2013                let mut reg = redis_registry().lock().unwrap();
2014                let entry = reg.touch_get_mut(h)
2015                    .ok_or_else(|| "redis.expire: closed or unknown ConnRedis handle".to_string())?;
2016                use redis::Commands;
2017                entry.conn.expire::<_, ()>(&key, ttl)
2018                    .map_err(|e| format!("redis.expire: {e}"))?;
2019                Ok(Value::Unit)
2020            }
2021            ("redis", "publish") => {
2022                let h = expect_redis_handle(args.first())?;
2023                let channel = expect_str(args.get(1))?.to_string();
2024                let msg = expect_str(args.get(2))?.to_string();
2025                let mut reg = redis_registry().lock().unwrap();
2026                let entry = reg.touch_get_mut(h)
2027                    .ok_or_else(|| "redis.publish: closed or unknown ConnRedis handle".to_string())?;
2028                use redis::Commands;
2029                let n: i64 = entry.conn.publish(&channel, &msg)
2030                    .map_err(|e| format!("redis.publish: {e}"))?;
2031                Ok(Value::Int(n))
2032            }
2033            // subscribe / psubscribe: blocking loops on dedicated connections.
2034            // Each inbound message calls the Lex closure in a fresh VM built
2035            // from `self.program` — same pattern as net.serve_fn's per-request
2036            // dispatch. Returns Unit (Nil) only if the connection drops.
2037            ("redis", "subscribe") => {
2038                let h = expect_redis_handle(args.first())?;
2039                let channel = expect_str(args.get(1))?.to_string();
2040                let closure = match args.into_iter().nth(2) {
2041                    Some(c @ Value::Closure { .. }) => c,
2042                    _ => return Err("redis.subscribe: handler must be a Closure".into()),
2043                };
2044                let program = self.program.clone()
2045                    .ok_or("redis.subscribe: no program; call DefaultHandler::with_program")?;
2046                let policy = self.policy.clone();
2047                let url = redis_registry().lock().unwrap()
2048                    .get_url(h)
2049                    .ok_or("redis.subscribe: closed or unknown ConnRedis handle")?;
2050                let client = redis::Client::open(url.as_str())
2051                    .map_err(|e| format!("redis.subscribe: {e}"))?;
2052                let mut conn = client.get_connection()
2053                    .map_err(|e| format!("redis.subscribe: {e}"))?;
2054                let mut pubsub = conn.as_pubsub();
2055                pubsub.subscribe(&channel)
2056                    .map_err(|e| format!("redis.subscribe: {e}"))?;
2057                loop {
2058                    let msg = pubsub.get_message()
2059                        .map_err(|e| format!("redis.subscribe: {e}"))?;
2060                    let ch: String = msg.get_channel_name().to_string();
2061                    let payload: String = msg.get_payload()
2062                        .map_err(|e| format!("redis.subscribe: payload: {e}"))?;
2063                    let handler = DefaultHandler::new(policy.clone())
2064                        .with_program(Arc::clone(&program));
2065                    let mut vm = Vm::with_handler(&program, Box::new(handler));
2066                    vm.invoke_closure_value(closure.clone(), vec![
2067                        Value::Str(ch.into()),
2068                        Value::Str(payload.into()),
2069                    ]).map_err(|e| format!("redis.subscribe: handler: {e:?}"))?;
2070                }
2071            }
2072            ("redis", "psubscribe") => {
2073                let h = expect_redis_handle(args.first())?;
2074                let pattern = expect_str(args.get(1))?.to_string();
2075                let closure = match args.into_iter().nth(2) {
2076                    Some(c @ Value::Closure { .. }) => c,
2077                    _ => return Err("redis.psubscribe: handler must be a Closure".into()),
2078                };
2079                let program = self.program.clone()
2080                    .ok_or("redis.psubscribe: no program; call DefaultHandler::with_program")?;
2081                let policy = self.policy.clone();
2082                let url = redis_registry().lock().unwrap()
2083                    .get_url(h)
2084                    .ok_or("redis.psubscribe: closed or unknown ConnRedis handle")?;
2085                let client = redis::Client::open(url.as_str())
2086                    .map_err(|e| format!("redis.psubscribe: {e}"))?;
2087                let mut conn = client.get_connection()
2088                    .map_err(|e| format!("redis.psubscribe: {e}"))?;
2089                let mut pubsub = conn.as_pubsub();
2090                pubsub.psubscribe(&pattern)
2091                    .map_err(|e| format!("redis.psubscribe: {e}"))?;
2092                loop {
2093                    let msg = pubsub.get_message()
2094                        .map_err(|e| format!("redis.psubscribe: {e}"))?;
2095                    let pat: String = msg.get_pattern()
2096                        .ok()
2097                        .and_then(|v: Option<String>| v)
2098                        .unwrap_or_else(|| pattern.clone());
2099                    let ch: String = msg.get_channel_name().to_string();
2100                    let payload: String = msg.get_payload()
2101                        .map_err(|e| format!("redis.psubscribe: payload: {e}"))?;
2102                    let handler = DefaultHandler::new(policy.clone())
2103                        .with_program(Arc::clone(&program));
2104                    let mut vm = Vm::with_handler(&program, Box::new(handler));
2105                    vm.invoke_closure_value(closure.clone(), vec![
2106                        Value::Str(pat.into()),
2107                        Value::Str(ch.into()),
2108                        Value::Str(payload.into()),
2109                    ]).map_err(|e| format!("redis.psubscribe: handler: {e:?}"))?;
2110                }
2111            }
2112            ("redis", "lpush") => {
2113                let h = expect_redis_handle(args.first())?;
2114                let key = expect_str(args.get(1))?.to_string();
2115                let val = expect_str(args.get(2))?.to_string();
2116                let mut reg = redis_registry().lock().unwrap();
2117                let entry = reg.touch_get_mut(h)
2118                    .ok_or_else(|| "redis.lpush: closed or unknown ConnRedis handle".to_string())?;
2119                use redis::Commands;
2120                let n: i64 = entry.conn.lpush(&key, &val)
2121                    .map_err(|e| format!("redis.lpush: {e}"))?;
2122                Ok(Value::Int(n))
2123            }
2124            ("redis", "rpush") => {
2125                let h = expect_redis_handle(args.first())?;
2126                let key = expect_str(args.get(1))?.to_string();
2127                let val = expect_str(args.get(2))?.to_string();
2128                let mut reg = redis_registry().lock().unwrap();
2129                let entry = reg.touch_get_mut(h)
2130                    .ok_or_else(|| "redis.rpush: closed or unknown ConnRedis handle".to_string())?;
2131                use redis::Commands;
2132                let n: i64 = entry.conn.rpush(&key, &val)
2133                    .map_err(|e| format!("redis.rpush: {e}"))?;
2134                Ok(Value::Int(n))
2135            }
2136            ("redis", "brpop") => {
2137                // timeout=0 means block indefinitely; the Lex runtime does not
2138                // treat this as a hung effect — it is the caller's intent.
2139                let h = expect_redis_handle(args.first())?;
2140                let key = expect_str(args.get(1))?.to_string();
2141                let timeout = expect_int(args.get(2))?;
2142                let mut reg = redis_registry().lock().unwrap();
2143                let entry = reg.touch_get_mut(h)
2144                    .ok_or_else(|| "redis.brpop: closed or unknown ConnRedis handle".to_string())?;
2145                use redis::Commands;
2146                // brpop returns Option<(String, String)>: (key, value).
2147                // We surface only the value to the Lex caller.
2148                let result: Option<(String, String)> = entry.conn
2149                    .brpop(&key, timeout as f64)
2150                    .map_err(|e| format!("redis.brpop: {e}"))?;
2151                match result {
2152                    Some((_, v)) => Ok(some(Value::Str(v.into()))),
2153                    None         => Ok(none()),
2154                }
2155            }
2156            ("redis", "llen") => {
2157                let h = expect_redis_handle(args.first())?;
2158                let key = expect_str(args.get(1))?.to_string();
2159                let mut reg = redis_registry().lock().unwrap();
2160                let entry = reg.touch_get_mut(h)
2161                    .ok_or_else(|| "redis.llen: closed or unknown ConnRedis handle".to_string())?;
2162                use redis::Commands;
2163                let n: i64 = entry.conn.llen(&key)
2164                    .map_err(|e| format!("redis.llen: {e}"))?;
2165                Ok(Value::Int(n))
2166            }
2167            ("redis", "hset") => {
2168                let h = expect_redis_handle(args.first())?;
2169                let key   = expect_str(args.get(1))?.to_string();
2170                let field = expect_str(args.get(2))?.to_string();
2171                let val   = expect_str(args.get(3))?.to_string();
2172                let mut reg = redis_registry().lock().unwrap();
2173                let entry = reg.touch_get_mut(h)
2174                    .ok_or_else(|| "redis.hset: closed or unknown ConnRedis handle".to_string())?;
2175                use redis::Commands;
2176                entry.conn.hset::<_, _, _, ()>(&key, &field, &val)
2177                    .map_err(|e| format!("redis.hset: {e}"))?;
2178                Ok(Value::Unit)
2179            }
2180            ("redis", "hget") => {
2181                let h = expect_redis_handle(args.first())?;
2182                let key   = expect_str(args.get(1))?.to_string();
2183                let field = expect_str(args.get(2))?.to_string();
2184                let mut reg = redis_registry().lock().unwrap();
2185                let entry = reg.touch_get_mut(h)
2186                    .ok_or_else(|| "redis.hget: closed or unknown ConnRedis handle".to_string())?;
2187                use redis::Commands;
2188                match entry.conn.hget::<_, _, Option<String>>(&key, &field) {
2189                    Ok(Some(v)) => Ok(some(Value::Str(v.into()))),
2190                    Ok(None)    => Ok(none()),
2191                    Err(e)      => Err(format!("redis.hget: {e}")),
2192                }
2193            }
2194            ("redis", "hdel") => {
2195                let h = expect_redis_handle(args.first())?;
2196                let key   = expect_str(args.get(1))?.to_string();
2197                let field = expect_str(args.get(2))?.to_string();
2198                let mut reg = redis_registry().lock().unwrap();
2199                let entry = reg.touch_get_mut(h)
2200                    .ok_or_else(|| "redis.hdel: closed or unknown ConnRedis handle".to_string())?;
2201                use redis::Commands;
2202                entry.conn.hdel::<_, _, ()>(&key, &field)
2203                    .map_err(|e| format!("redis.hdel: {e}"))?;
2204                Ok(Value::Unit)
2205            }
2206            ("redis", "hgetall") => {
2207                let h = expect_redis_handle(args.first())?;
2208                let key = expect_str(args.get(1))?.to_string();
2209                let mut reg = redis_registry().lock().unwrap();
2210                let entry = reg.touch_get_mut(h)
2211                    .ok_or_else(|| "redis.hgetall: closed or unknown ConnRedis handle".to_string())?;
2212                use redis::Commands;
2213                let map: std::collections::HashMap<String, String> = entry.conn
2214                    .hgetall(&key)
2215                    .map_err(|e| format!("redis.hgetall: {e}"))?;
2216                let pairs: Vec<Value> = map.into_iter()
2217                    .map(|(k, v)| Value::Tuple(vec![Value::Str(k.into()), Value::Str(v.into())]))
2218                    .collect();
2219                Ok(Value::List(pairs.into()))
2220            }
2221
2222            // `proc.spawn` was removed with the `std.proc` module (#678);
2223            // the blocking-capture path now lives at `process.run`, handled
2224            // in the `kind == "process"` block above.
2225            other => Err(format!("unsupported effect {}.{}", other.0, other.1)),
2226        }
2227    }
2228
2229    /// `list.par_map` worker-handler factory (#305 slice 2).
2230    ///
2231    /// Builds a fresh `DefaultHandler` per worker that shares the
2232    /// budget pool with the parent (`Arc<AtomicU64>`) so a parallel
2233    /// batch can't escape the run-wide budget ceiling. Other state
2234    /// is intentionally split per-worker:
2235    ///
2236    /// - `sink`: a `StdoutSink` per worker. Tests that capture
2237    ///   output via a `SharedSink` wrapped in `Arc<Mutex<…>>` see
2238    ///   each worker as a fresh handler. Print interleaving on
2239    ///   stdout is acceptable; tests that need ordered capture run
2240    ///   workloads serially anyway.
2241    /// - `mcp_clients`: a fresh per-worker LRU cache. The parent's
2242    ///   subprocess handles can't be shared across threads without
2243    ///   mutex-serialising every MCP call, which would defeat the
2244    ///   parallelism. Cache hit rate is sub-optimal across the
2245    ///   first call per worker; warmed caches still amortise within
2246    ///   a worker.
2247    /// - `chat_registry`: cloned `Arc<ChatRegistry>` so all workers
2248    ///   route into the same chat dispatch layer.
2249    /// - `program`: cloned `Arc<Program>` so `net.serve` (if a
2250    ///   worker invokes it) sees the same compiled program.
2251    fn spawn_for_worker(&self) -> Option<Box<dyn lex_bytecode::vm::EffectHandler + Send>> {
2252        let mut fresh = DefaultHandler::new(self.policy.clone());
2253        // Share the budget pool atomically — slice 2's correctness
2254        // contract: parallel work counts against the same ceiling.
2255        fresh.budget_remaining = std::sync::Arc::clone(&self.budget_remaining);
2256        fresh.budget_ceiling = self.budget_ceiling;
2257        fresh.read_root = self.read_root.clone();
2258        fresh.program = self.program.clone();
2259        fresh.chat_registry = self.chat_registry.clone();
2260        // #305 slice 3: share the stream registry across workers so
2261        // a stream produced on one thread (or the parent) is
2262        // consumable on any other. The registry is already
2263        // `Arc<Mutex<…>>` so concurrent access is safe.
2264        fresh.streams = std::sync::Arc::clone(&self.streams);
2265        fresh.next_stream_id = std::sync::Arc::clone(&self.next_stream_id);
2266        fresh.program_args = self.program_args.clone();
2267        Some(Box::new(fresh))
2268    }
2269}
2270
2271/// Blocks the calling thread, accepts incoming HTTP requests on
2272/// `127.0.0.1:port`, and dispatches each through the named Lex
2273/// stage. Each request gets a fresh `Vm`; the program and policy
2274/// are shared.
2275///
2276/// Handler signature in Lex (by convention):
2277///   fn <name>(req :: Record { method :: Str, path :: Str, body :: Str })
2278///        -> Record { status :: Int, body :: Str }
2279/// PEM-encoded certificate + private key, both as raw bytes.
2280pub struct TlsConfig {
2281    pub cert: Vec<u8>,
2282    pub key: Vec<u8>,
2283}
2284
2285fn serve_http(
2286    port: u16,
2287    handler_name: String,
2288    program: Arc<Program>,
2289    policy: Policy,
2290    tls: Option<TlsConfig>,
2291    opts: ServeOpts,
2292) -> Result<Value, String> {
2293    match tls {
2294        None => serve_http_plain(port, handler_name, program, policy, opts),
2295        Some(cfg) => serve_http_tls_legacy(port, handler_name, program, policy, cfg),
2296    }
2297}
2298
2299/// Hyper 1.x + Tokio multi-thread HTTP/1.1 server for `net.serve`.
2300/// Each connection is accepted in an async task; the synchronous Lex VM
2301/// call runs inside `spawn_blocking` so it doesn't block the executor.
2302///
2303/// `LEX_NET_INLINE_VM=1` (or `=true`) skips the `spawn_blocking` hop and
2304/// runs the VM directly on the tokio worker. Faster for handlers that
2305/// return in tens of microseconds; pathological if handlers do real
2306/// CPU/blocking work, since they stall the worker. Experimental — see
2307/// lex-lang issue #431.
2308fn serve_http_plain(
2309    port: u16,
2310    handler_name: String,
2311    program: Arc<Program>,
2312    policy: Policy,
2313    opts: ServeOpts,
2314) -> Result<Value, String> {
2315    use http_body_util::BodyExt as _;
2316    use hyper::server::conn::http1;
2317    use hyper::service::service_fn;
2318    use hyper_util::rt::{TokioExecutor, TokioIo};
2319    use hyper_util::server::conn::auto;
2320    use tokio::net::TcpListener as TokioTcpListener;
2321
2322    let inline_vm = opts.inline_vm;
2323    let http2 = opts.http2;
2324    let host = opts.host.clone();
2325    let rt = tokio::runtime::Builder::new_multi_thread()
2326        .enable_all()
2327        .build()
2328        .map_err(|e| format!("net.serve: tokio runtime: {e}"))?;
2329    rt.block_on(async move {
2330        let listener = TokioTcpListener::bind((host.as_str(), port))
2331            .await
2332            .map_err(|e| format!("net.serve bind {host}:{port}: {e}"))?;
2333        eprintln!(
2334            "net.serve: listening on http://{host}:{port}{}{}",
2335            if inline_vm { " (inline-vm)" } else { "" },
2336            if http2 { " (http1+http2)" } else { "" }
2337        );
2338        loop {
2339            let (stream, _) = listener
2340                .accept()
2341                .await
2342                .map_err(|e| format!("net.serve accept: {e}"))?;
2343            let io = TokioIo::new(stream);
2344            let program = Arc::clone(&program);
2345            let policy = policy.clone();
2346            let handler_name = handler_name.clone();
2347            tokio::spawn(async move {
2348                let program2 = Arc::clone(&program);
2349                let policy2 = policy.clone();
2350                let handler_name2 = handler_name.clone();
2351                let svc = service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
2352                    let program = Arc::clone(&program2);
2353                    let policy = policy2.clone();
2354                    let handler_name = handler_name2.clone();
2355                    async move {
2356                        let (parts, body) = req.into_parts();
2357                        let body_bytes = body
2358                            .collect()
2359                            .await
2360                            .map(|c| c.to_bytes())
2361                            .unwrap_or_default();
2362                        let result = if inline_vm {
2363                            // Inline path — run the VM on this tokio worker.
2364                            // Cheap when handlers return in microseconds; will
2365                            // stall the worker on heavy handlers (caveat per #431).
2366                            let lex_req = build_request_value_parts(&parts, &body_bytes);
2367                            let handler = DefaultHandler::new(policy)
2368                                .with_program(Arc::clone(&program));
2369                            let mut vm = Vm::with_handler(&program, Box::new(handler));
2370                            let r = vm.call(&handler_name, vec![lex_req]);
2371                            // Unpack inline so the VM is still in
2372                            // scope (#463 wire-up).
2373                            Ok(r.map(|v| unpack_response(&mut vm, &v)))
2374                        } else {
2375                            tokio::task::spawn_blocking(move || {
2376                                let lex_req = build_request_value_parts(&parts, &body_bytes);
2377                                let handler = DefaultHandler::new(policy)
2378                                    .with_program(Arc::clone(&program));
2379                                let mut vm = Vm::with_handler(&program, Box::new(handler));
2380                                let r = vm.call(&handler_name, vec![lex_req]);
2381                                r.map(|v| unpack_response(&mut vm, &v))
2382                            })
2383                            .await
2384                        };
2385                        Ok::<_, std::convert::Infallible>(match result {
2386                            Ok(Ok(unpacked)) => build_hyper_response(unpacked),
2387                            Ok(Err(e)) => error_response(500, &format!("internal error: {e}")),
2388                            Err(e) => error_response(500, &format!("task panicked: {e}")),
2389                        })
2390                    }
2391                });
2392                let result = if http2 {
2393                    auto::Builder::new(TokioExecutor::new())
2394                        .serve_connection(io, svc)
2395                        .await
2396                        .map_err(|e| e.to_string())
2397                } else {
2398                    http1::Builder::new()
2399                        .serve_connection(io, svc)
2400                        .await
2401                        .map_err(|e| e.to_string())
2402                };
2403                if let Err(e) = result {
2404                    eprintln!("net.serve: connection error: {e}");
2405                }
2406            });
2407        }
2408    })
2409}
2410
2411/// TLS path: still uses tiny_http pending a tokio-rustls migration.
2412fn serve_http_tls_legacy(
2413    port: u16,
2414    handler_name: String,
2415    program: Arc<Program>,
2416    policy: Policy,
2417    cfg: TlsConfig,
2418) -> Result<Value, String> {
2419    let ssl = tiny_http::SslConfig {
2420        certificate: cfg.cert,
2421        private_key: cfg.key,
2422    };
2423    let server = tiny_http::Server::https(("0.0.0.0", port), ssl)
2424        .map_err(|e| format!("net.serve_tls bind {port}: {e}"))?;
2425    eprintln!("net.serve: listening on https://0.0.0.0:{port}");
2426    for req in server.incoming_requests() {
2427        let program = Arc::clone(&program);
2428        let policy = policy.clone();
2429        let handler_name = handler_name.clone();
2430        std::thread::spawn(move || handle_request_tls(req, program, policy, handler_name));
2431    }
2432    Ok(Value::Unit)
2433}
2434
2435fn handle_request_tls(
2436    mut req: tiny_http::Request,
2437    program: Arc<Program>,
2438    policy: Policy,
2439    handler_name: String,
2440) {
2441    let lex_req = build_request_value_tiny(&mut req);
2442    let handler = DefaultHandler::new(policy).with_program(Arc::clone(&program));
2443    let mut vm = Vm::with_handler(&program, Box::new(handler));
2444    match vm.call(&handler_name, vec![lex_req]) {
2445        Ok(resp) => {
2446            // Drain lazy iters + read response fields straight out of
2447            // any arena handles while the VM is still in scope — see
2448            // #477 and `docs/design/arena-plumbing.md` § "Status
2449            // update (2026-06-05)" for why this is a single fused
2450            // step now.
2451            let (status, body, headers) = unpack_response(&mut vm, &resp);
2452            respond_with_body_tls(req, status, body, headers);
2453        }
2454        Err(e) => {
2455            let response = tiny_http::Response::from_string(format!("internal error: {e}"))
2456                .with_status_code(500);
2457            let _ = req.respond(response);
2458        }
2459    }
2460}
2461
2462/// Hyper 1.x + Tokio multi-thread HTTP/1.1 server for `net.serve_fn`.
2463///
2464/// `LEX_NET_INLINE_VM=1` skips `spawn_blocking` — see `serve_http_plain`'s
2465/// doc-comment for the tradeoffs. Same env var gates both paths.
2466fn serve_http_fn(
2467    port: u16,
2468    closure: Value,
2469    program: Arc<Program>,
2470    policy: Policy,
2471    opts: ServeOpts,
2472) -> Result<Value, String> {
2473    use http_body_util::BodyExt as _;
2474    use hyper::server::conn::http1;
2475    use hyper::service::service_fn;
2476    use hyper_util::rt::{TokioExecutor, TokioIo};
2477    use hyper_util::server::conn::auto;
2478    use tokio::net::TcpListener as TokioTcpListener;
2479
2480    let inline_vm = opts.inline_vm;
2481    let http2 = opts.http2;
2482    let host = opts.host.clone();
2483    let rt = tokio::runtime::Builder::new_multi_thread()
2484        .enable_all()
2485        .build()
2486        .map_err(|e| format!("net.serve_fn: tokio runtime: {e}"))?;
2487    rt.block_on(async move {
2488        let listener = TokioTcpListener::bind((host.as_str(), port))
2489            .await
2490            .map_err(|e| format!("net.serve_fn bind {host}:{port}: {e}"))?;
2491        eprintln!(
2492            "net.serve_fn: listening on http://{host}:{port}{}{}",
2493            if inline_vm { " (inline-vm)" } else { "" },
2494            if http2 { " (http1+http2)" } else { "" }
2495        );
2496        loop {
2497            let (stream, _) = listener
2498                .accept()
2499                .await
2500                .map_err(|e| format!("net.serve_fn accept: {e}"))?;
2501            let io = TokioIo::new(stream);
2502            let program = Arc::clone(&program);
2503            let policy = policy.clone();
2504            let closure = closure.clone();
2505            tokio::spawn(async move {
2506                let program2 = Arc::clone(&program);
2507                let policy2 = policy.clone();
2508                let closure2 = closure.clone();
2509                let svc = service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
2510                    let program = Arc::clone(&program2);
2511                    let policy = policy2.clone();
2512                    let closure = closure2.clone();
2513                    async move {
2514                        let (parts, body) = req.into_parts();
2515                        let body_bytes = body
2516                            .collect()
2517                            .await
2518                            .map(|c| c.to_bytes())
2519                            .unwrap_or_default();
2520                        let result = if inline_vm {
2521                            let lex_req = build_request_value_parts(&parts, &body_bytes);
2522                            let handler = DefaultHandler::new(policy)
2523                                .with_program(Arc::clone(&program));
2524                            let mut vm = Vm::with_handler(&program, Box::new(handler));
2525                            // #463 scaffolding — bracket the user
2526                            // handler with a request scope so the
2527                            // arena lifecycle is exercised. The
2528                            // arena itself is unused today; this
2529                            // proves the lifecycle is sound for the
2530                            // follow-on Value-rep slice.
2531                            let scope = vm.enter_request_scope();
2532                            let r = vm.invoke_closure_value(closure, vec![lex_req]);
2533                            // Unpack inline so the VM is still in
2534                            // scope for both lazy-iter draining and
2535                            // slab-direct field reads (#463).
2536                            let r = r.map(|v| unpack_response(&mut vm, &v));
2537                            vm.exit_request_scope(scope);
2538                            Ok(r)
2539                        } else {
2540                            tokio::task::spawn_blocking(move || {
2541                                let lex_req = build_request_value_parts(&parts, &body_bytes);
2542                                let handler = DefaultHandler::new(policy)
2543                                    .with_program(Arc::clone(&program));
2544                                let mut vm = Vm::with_handler(&program, Box::new(handler));
2545                                let scope = vm.enter_request_scope();
2546                                let r = vm.invoke_closure_value(closure, vec![lex_req]);
2547                                let r = r.map(|v| unpack_response(&mut vm, &v));
2548                                vm.exit_request_scope(scope);
2549                                r
2550                            })
2551                            .await
2552                        };
2553                        Ok::<_, std::convert::Infallible>(match result {
2554                            Ok(Ok(unpacked)) => build_hyper_response(unpacked),
2555                            Ok(Err(e)) => error_response(500, &format!("internal error: {e}")),
2556                            Err(e) => error_response(500, &format!("task panicked: {e}")),
2557                        })
2558                    }
2559                });
2560                let result = if http2 {
2561                    auto::Builder::new(TokioExecutor::new())
2562                        .serve_connection(io, svc)
2563                        .await
2564                        .map_err(|e| e.to_string())
2565                } else {
2566                    http1::Builder::new()
2567                        .serve_connection(io, svc)
2568                        .await
2569                        .map_err(|e| e.to_string())
2570                };
2571                if let Err(e) = result {
2572                    eprintln!("net.serve_fn: connection error: {e}");
2573                }
2574            });
2575        }
2576    })
2577}
2578
2579/// Compiled segment of a route pattern. Patterns are split on `/`
2580/// once at registration time so the per-request match loop is just a
2581/// length check + segment-by-segment compare.
2582#[derive(Clone, Debug)]
2583pub(crate) enum RouteSeg {
2584    Literal(String),
2585    /// `:name` capture — binds the request segment under `name` in
2586    /// `req.path_params`.
2587    Param(String),
2588}
2589
2590/// Compile a `:name`-style pattern (e.g. `"/users/:id/posts"`) into a
2591/// segment list. Errors out at registration time so bad patterns
2592/// surface before the server binds, not on the first matching request.
2593fn compile_path_pattern(pat: &str) -> Result<Vec<RouteSeg>, String> {
2594    if pat.is_empty() {
2595        return Err("path pattern must be non-empty (use \"/\" for the root)".into());
2596    }
2597    if !pat.starts_with('/') {
2598        return Err(format!("path pattern must start with '/' (got {pat:?})"));
2599    }
2600    let mut segs = Vec::new();
2601    for raw in pat.split('/') {
2602        if let Some(name) = raw.strip_prefix(':') {
2603            if name.is_empty() {
2604                return Err(format!(
2605                    ":-segment in pattern {pat:?} must have a name (e.g. :id)"
2606                ));
2607            }
2608            segs.push(RouteSeg::Param(name.to_string()));
2609        } else {
2610            segs.push(RouteSeg::Literal(raw.to_string()));
2611        }
2612    }
2613    Ok(segs)
2614}
2615
2616/// Attempt to match a request `path` against a compiled pattern. On
2617/// success returns the captured `:name` segments as a Lex-shaped map
2618/// keyed by `MapKey::Str(name)`; on mismatch returns `None`. Strict
2619/// segment-count match: trailing slashes matter (caller registers
2620/// both forms if both should match).
2621fn match_path_pattern(
2622    segs: &[RouteSeg],
2623    path: &str,
2624) -> Option<std::collections::BTreeMap<lex_bytecode::MapKey, Value>> {
2625    let path_segs: Vec<&str> = path.split('/').collect();
2626    if path_segs.len() != segs.len() {
2627        return None;
2628    }
2629    let mut params = std::collections::BTreeMap::new();
2630    for (pat, p) in segs.iter().zip(path_segs.iter()) {
2631        match pat {
2632            RouteSeg::Literal(lit) => {
2633                if lit != p {
2634                    return None;
2635                }
2636            }
2637            RouteSeg::Param(name) => {
2638                params.insert(
2639                    lex_bytecode::MapKey::Str(name.clone()),
2640                    Value::Str((*p).into()),
2641                );
2642            }
2643        }
2644    }
2645    Some(params)
2646}
2647
2648/// Decode the `routes` argument of `net.serve_routed` into a vector
2649/// of `(uppercased-method-or-"*", compiled-pattern, handler-closure)`.
2650/// Validates and pre-compiles up front so malformed routes fail before
2651/// the server starts.
2652fn decode_routes_arg(
2653    v: Value,
2654) -> Result<Vec<(String, Vec<RouteSeg>, Value)>, String> {
2655    let list = match v {
2656        Value::List(xs) => xs,
2657        _ => return Err("net.serve_routed: routes must be a List".into()),
2658    };
2659    let mut out = Vec::with_capacity(list.len());
2660    for (i, item) in list.into_iter().enumerate() {
2661        let tup = match item {
2662            Value::Tuple(xs) if xs.len() == 3 => xs,
2663            other => return Err(format!(
2664                "net.serve_routed: route #{i} must be a (method, pattern, handler) 3-tuple, got {other:?}"
2665            )),
2666        };
2667        let mut it = tup.into_iter();
2668        let method_raw = match it.next() {
2669            Some(Value::Str(s)) => s.to_string(),
2670            _ => return Err(format!("net.serve_routed: route #{i} method must be Str")),
2671        };
2672        // Normalise method to uppercase for matching. "*" stays as-is.
2673        let method = if method_raw == "*" { method_raw } else { method_raw.to_uppercase() };
2674        let pattern = match it.next() {
2675            Some(Value::Str(s)) => s.to_string(),
2676            _ => return Err(format!("net.serve_routed: route #{i} path-pattern must be Str")),
2677        };
2678        let segs = compile_path_pattern(&pattern)
2679            .map_err(|e| format!("net.serve_routed: route #{i} ({pattern:?}): {e}"))?;
2680        let closure = match it.next() {
2681            Some(c @ Value::Closure { .. }) => c,
2682            _ => return Err(format!("net.serve_routed: route #{i} handler must be a closure")),
2683        };
2684        out.push((method, segs, closure));
2685    }
2686    Ok(out)
2687}
2688
2689/// Pick the first matching route for `(method, path)` and return its
2690/// handler closure plus captured path-params. Method match is
2691/// case-insensitive vs the request (already uppercased at decode
2692/// time); `"*"` in a route matches any method.
2693pub(crate) fn dispatch_route<'a>(
2694    routes: &'a [(String, Vec<RouteSeg>, Value)],
2695    req_method: &str,
2696    req_path: &str,
2697) -> Option<(&'a Value, std::collections::BTreeMap<lex_bytecode::MapKey, Value>)> {
2698    let req_method_upper = req_method.to_ascii_uppercase();
2699    for (m, segs, closure) in routes {
2700        if m != "*" && m != &req_method_upper {
2701            continue;
2702        }
2703        if let Some(params) = match_path_pattern(segs, req_path) {
2704            return Some((closure, params));
2705        }
2706    }
2707    None
2708}
2709
2710/// Overwrite the `path_params` field on a Request record with the
2711/// captured map. Request records are always built with an empty
2712/// `path_params` field, so this just updates the existing slot.
2713pub(crate) fn stamp_path_params(
2714    req: &mut Value,
2715    params: std::collections::BTreeMap<lex_bytecode::MapKey, Value>,
2716) {
2717    if let Value::Record { fields: rec, .. } = req {
2718        rec.insert("path_params".into(), Value::Map(params));
2719    }
2720}
2721
2722/// Hyper 1.x + Tokio multi-thread HTTP/1.1 server for `net.serve_routed`.
2723/// Mirrors `serve_http_fn` (#431 inline-vm gate also applies); the only
2724/// difference is that route dispatch picks the closure per-request from
2725/// the precompiled `routes` table, falling back to the `fallback`
2726/// closure when no route matches.
2727fn serve_http_routed(
2728    port: u16,
2729    routes: Vec<(String, Vec<RouteSeg>, Value)>,
2730    fallback: Value,
2731    program: Arc<Program>,
2732    policy: Policy,
2733    opts: ServeOpts,
2734) -> Result<Value, String> {
2735    use http_body_util::BodyExt as _;
2736    use hyper::server::conn::http1;
2737    use hyper::service::service_fn;
2738    use hyper_util::rt::{TokioExecutor, TokioIo};
2739    use hyper_util::server::conn::auto;
2740    use tokio::net::TcpListener as TokioTcpListener;
2741
2742    let inline_vm = opts.inline_vm;
2743    let http2 = opts.http2;
2744    let host = opts.host.clone();
2745    let routes = Arc::new(routes);
2746    let rt = tokio::runtime::Builder::new_multi_thread()
2747        .enable_all()
2748        .build()
2749        .map_err(|e| format!("net.serve_routed: tokio runtime: {e}"))?;
2750    rt.block_on(async move {
2751        let listener = TokioTcpListener::bind((host.as_str(), port))
2752            .await
2753            .map_err(|e| format!("net.serve_routed bind {host}:{port}: {e}"))?;
2754        eprintln!(
2755            "net.serve_routed: listening on http://{host}:{port} ({} routes{}{})",
2756            routes.len(),
2757            if inline_vm { ", inline-vm" } else { "" },
2758            if http2 { ", http1+http2" } else { "" }
2759        );
2760        loop {
2761            let (stream, _) = listener
2762                .accept()
2763                .await
2764                .map_err(|e| format!("net.serve_routed accept: {e}"))?;
2765            let io = TokioIo::new(stream);
2766            let program = Arc::clone(&program);
2767            let policy = policy.clone();
2768            let routes = Arc::clone(&routes);
2769            let fallback = fallback.clone();
2770            tokio::spawn(async move {
2771                let program2 = Arc::clone(&program);
2772                let policy2 = policy.clone();
2773                let routes2 = Arc::clone(&routes);
2774                let fallback2 = fallback.clone();
2775                let svc = service_fn(move |req: hyper::Request<hyper::body::Incoming>| {
2776                    let program = Arc::clone(&program2);
2777                    let policy = policy2.clone();
2778                    let routes = Arc::clone(&routes2);
2779                    let fallback = fallback2.clone();
2780                    async move {
2781                        let (parts, body) = req.into_parts();
2782                        let body_bytes = body
2783                            .collect()
2784                            .await
2785                            .map(|c| c.to_bytes())
2786                            .unwrap_or_default();
2787                        let method = parts.method.as_str().to_string();
2788                        let path = match parts.uri.path() {
2789                            "" => "/".to_string(),
2790                            p => p.to_string(),
2791                        };
2792                        let result = if inline_vm {
2793                            let mut lex_req = build_request_value_parts(&parts, &body_bytes);
2794                            let (closure, params) = match dispatch_route(&routes, &method, &path) {
2795                                Some((c, p)) => (c.clone(), p),
2796                                None => (fallback.clone(), std::collections::BTreeMap::new()),
2797                            };
2798                            stamp_path_params(&mut lex_req, params);
2799                            let handler = DefaultHandler::new(policy)
2800                                .with_program(Arc::clone(&program));
2801                            let mut vm = Vm::with_handler(&program, Box::new(handler));
2802                            let r = vm.invoke_closure_value(closure, vec![lex_req]);
2803                            // Unpack inline so the VM is still in
2804                            // scope (#463 wire-up, see arena-plumbing.md).
2805                            Ok(r.map(|v| unpack_response(&mut vm, &v)))
2806                        } else {
2807                            tokio::task::spawn_blocking(move || {
2808                                let mut lex_req = build_request_value_parts(&parts, &body_bytes);
2809                                let (closure, params) = match dispatch_route(&routes, &method, &path) {
2810                                    Some((c, p)) => (c.clone(), p),
2811                                    None => (fallback.clone(), std::collections::BTreeMap::new()),
2812                                };
2813                                stamp_path_params(&mut lex_req, params);
2814                                let handler = DefaultHandler::new(policy)
2815                                    .with_program(Arc::clone(&program));
2816                                let mut vm = Vm::with_handler(&program, Box::new(handler));
2817                                let r = vm.invoke_closure_value(closure, vec![lex_req]);
2818                                r.map(|v| unpack_response(&mut vm, &v))
2819                            })
2820                            .await
2821                        };
2822                        Ok::<_, std::convert::Infallible>(match result {
2823                            Ok(Ok(unpacked)) => build_hyper_response(unpacked),
2824                            Ok(Err(e)) => error_response(500, &format!("internal error: {e}")),
2825                            Err(e) => error_response(500, &format!("task panicked: {e}")),
2826                        })
2827                    }
2828                });
2829                let result = if http2 {
2830                    auto::Builder::new(TokioExecutor::new())
2831                        .serve_connection(io, svc)
2832                        .await
2833                        .map_err(|e| e.to_string())
2834                } else {
2835                    http1::Builder::new()
2836                        .serve_connection(io, svc)
2837                        .await
2838                        .map_err(|e| e.to_string())
2839                };
2840                if let Err(e) = result {
2841                    eprintln!("net.serve_routed: connection error: {e}");
2842                }
2843            });
2844        }
2845    })
2846}
2847
2848/// Read `LEX_NET_INLINE_VM` and report whether the runtime should skip
2849/// `spawn_blocking` on the per-request VM call. Accepts `1` / `true`
2850/// (case-insensitive); anything else (including unset) keeps the
2851/// default `spawn_blocking` behaviour. See issue #431.
2852fn env_inline_vm() -> bool {
2853    match std::env::var("LEX_NET_INLINE_VM") {
2854        Ok(v) => {
2855            let s = v.trim().to_ascii_lowercase();
2856            s == "1" || s == "true"
2857        }
2858        Err(_) => false,
2859    }
2860}
2861
2862/// Server-config record threaded through `serve_http_plain` / `_fn` /
2863/// `_routed`. Built from env vars on the legacy `net.serve*` paths
2864/// (`ServeOpts::from_env`) or decoded from a user-supplied Lex record
2865/// literal on the new `net.serve*_with` paths (`decode_serve_opts`).
2866/// See lex-lang#497 for the design rationale.
2867#[derive(Debug, Clone)]
2868pub(crate) struct ServeOpts {
2869    pub(crate) http2: bool,
2870    pub(crate) inline_vm: bool,
2871    pub(crate) host: String,
2872}
2873
2874impl ServeOpts {
2875    /// Default values that match the legacy behaviour with env vars
2876    /// honoured. Use this when entering via `net.serve`, `net.serve_fn`,
2877    /// or `net.serve_routed` — preserves backwards compatibility.
2878    fn from_env() -> Self {
2879        Self {
2880            http2: env_http2(),
2881            inline_vm: env_inline_vm(),
2882            host: "0.0.0.0".to_string(),
2883        }
2884    }
2885
2886    /// Hard-coded defaults returned by `net.default_opts()`. Does NOT
2887    /// consult env vars — the `*_with` paths read the opts record
2888    /// literally, so the env-var escape hatch only applies to legacy
2889    /// callers (`net.serve` et al).
2890    fn lex_defaults() -> Self {
2891        Self {
2892            http2: false,
2893            inline_vm: false,
2894            host: "0.0.0.0".to_string(),
2895        }
2896    }
2897
2898    /// Convert to a Lex `Value::Record` for return from `default_opts()`.
2899    fn to_value(&self) -> Value {
2900        let mut rec = indexmap::IndexMap::new();
2901        rec.insert("http2".to_string(),     Value::Bool(self.http2));
2902        rec.insert("inline_vm".to_string(), Value::Bool(self.inline_vm));
2903        rec.insert("host".to_string(),      Value::Str(self.host.clone().into()));
2904        Value::record_dynamic(rec)
2905    }
2906}
2907
2908/// Decode a `ServeOpts` from a Lex record literal. Fields are
2909/// required — the type-checker has already verified the shape, so
2910/// here we just project them out. Any deviation from the expected
2911/// shape is treated as an internal-consistency error.
2912fn decode_serve_opts(v: &Value) -> Result<ServeOpts, String> {
2913    let rec = match v {
2914        Value::Record { fields: r, .. } => r,
2915        other => return Err(format!("opts must be a Record, got {other:?}")),
2916    };
2917    let http2 = match rec.get("http2") {
2918        Some(Value::Bool(b)) => *b,
2919        _ => return Err("opts.http2 must be Bool".into()),
2920    };
2921    let inline_vm = match rec.get("inline_vm") {
2922        Some(Value::Bool(b)) => *b,
2923        _ => return Err("opts.inline_vm must be Bool".into()),
2924    };
2925    let host = match rec.get("host") {
2926        Some(Value::Str(s)) => s.to_string(),
2927        _ => return Err("opts.host must be Str".into()),
2928    };
2929    Ok(ServeOpts { http2, inline_vm, host })
2930}
2931
2932// ── tls.* and net.serve_quic* dispatch helpers (#496) ──────────────
2933//
2934// `TlsConfig` is opaque in the type system (a `Ty::Con("TlsConfig",…)`)
2935// but at runtime it's a `Value::Record({cert :: Bytes, key :: Bytes})`
2936// carrying the PEM-encoded chain + private key. The opacity matters
2937// because we may switch the in-runtime representation to a Resource
2938// handle later (e.g. to keep the private key out of GC-visible
2939// memory) without breaking source code.
2940
2941fn make_tls_config_value(cert_pem: Vec<u8>, key_pem: Vec<u8>) -> Value {
2942    let mut rec = indexmap::IndexMap::new();
2943    rec.insert("cert".into(), Value::Bytes(cert_pem));
2944    rec.insert("key".into(),  Value::Bytes(key_pem));
2945    Value::record_dynamic(rec)
2946}
2947
2948#[cfg(feature = "quic")]
2949fn decode_tls_config(v: &Value) -> Result<crate::quic::QuicTls, String> {
2950    let rec = match v {
2951        Value::Record { fields: r, .. } => r,
2952        other => return Err(format!("TlsConfig: expected Record, got {other:?}")),
2953    };
2954    let cert = match rec.get("cert") {
2955        Some(Value::Bytes(b)) => b.to_vec(),
2956        _ => return Err("TlsConfig.cert: must be Bytes".into()),
2957    };
2958    let key = match rec.get("key") {
2959        Some(Value::Bytes(b)) => b.to_vec(),
2960        _ => return Err("TlsConfig.key: must be Bytes".into()),
2961    };
2962    Ok(crate::quic::QuicTls { cert_pem: cert, key_pem: key })
2963}
2964
2965fn dispatch_tls_from_pem_files(
2966    handler: &DefaultHandler,
2967    args: Vec<Value>,
2968) -> Result<Value, String> {
2969    let cert_path = expect_str(args.first())?.to_string();
2970    let key_path  = expect_str(args.get(1))?.to_string();
2971    let cert_resolved = handler.resolve_read_path(&cert_path);
2972    let key_resolved  = handler.resolve_read_path(&key_path);
2973    if !handler.policy.allow_fs_read.is_empty() {
2974        let allowed = |p: &std::path::Path| -> bool {
2975            handler.policy.allow_fs_read.iter().any(|a| p.starts_with(a))
2976        };
2977        if !allowed(&cert_resolved) {
2978            return Ok(err(Value::Str(
2979                format!("tls.from_pem_files: cert `{cert_path}` outside --allow-fs-read").into(),
2980            )));
2981        }
2982        if !allowed(&key_resolved) {
2983            return Ok(err(Value::Str(
2984                format!("tls.from_pem_files: key `{key_path}` outside --allow-fs-read").into(),
2985            )));
2986        }
2987    }
2988    let cert = match std::fs::read(&cert_resolved) {
2989        Ok(b) => b,
2990        Err(e) => return Ok(err(Value::Str(format!("read cert {cert_path}: {e}").into()))),
2991    };
2992    let key = match std::fs::read(&key_resolved) {
2993        Ok(b) => b,
2994        Err(e) => return Ok(err(Value::Str(format!("read key {key_path}: {e}").into()))),
2995    };
2996    Ok(ok(make_tls_config_value(cert, key)))
2997}
2998
2999#[cfg(feature = "quic")]
3000fn dispatch_tls_self_signed(args: Vec<Value>) -> Result<Value, String> {
3001    let hostname = expect_str(args.first())?.to_string();
3002    match crate::quic::self_signed_pem(&hostname) {
3003        Ok((cert, key)) => Ok(ok(make_tls_config_value(cert, key))),
3004        Err(e) => Ok(err(Value::Str(format!("tls.self_signed: {e}").into()))),
3005    }
3006}
3007
3008#[cfg(not(feature = "quic"))]
3009fn dispatch_tls_self_signed(_args: Vec<Value>) -> Result<Value, String> {
3010    Ok(err(Value::Str(
3011        "tls.self_signed: lex-runtime was compiled without the `quic` feature (needed for rcgen)".into(),
3012    )))
3013}
3014
3015impl DefaultHandler {
3016    #[cfg(feature = "quic")]
3017    fn dispatch_serve_quic_named(&self, args: Vec<Value>) -> Result<Value, String> {
3018        let port = match args.first() {
3019            Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
3020            _ => return Err("net.serve_quic(port, tls, handler): port must be Int 0..=65535".into()),
3021        };
3022        let tls = decode_tls_config(args.get(1)
3023            .ok_or_else(|| "net.serve_quic(port, tls, handler): missing tls".to_string())?)?;
3024        let handler_name = expect_str(args.get(2))?.to_string();
3025        let program = self.program.clone()
3026            .ok_or_else(|| "net.serve_quic requires a Program reference; use DefaultHandler::with_program".to_string())?;
3027        let policy = self.policy.clone();
3028        crate::quic::serve_http3_named(port, handler_name, tls, program, policy, ServeOpts::from_env())
3029    }
3030
3031    #[cfg(feature = "quic")]
3032    fn dispatch_serve_quic_fn(&self, args: Vec<Value>) -> Result<Value, String> {
3033        let port = match args.first() {
3034            Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
3035            _ => return Err("net.serve_quic_fn(port, tls, handler): port must be Int 0..=65535".into()),
3036        };
3037        let tls = decode_tls_config(args.get(1)
3038            .ok_or_else(|| "net.serve_quic_fn(port, tls, handler): missing tls".to_string())?)?;
3039        let closure = match args.into_iter().nth(2) {
3040            Some(c @ Value::Closure { .. }) => c,
3041            _ => return Err("net.serve_quic_fn(port, tls, handler): handler must be a closure".into()),
3042        };
3043        let program = self.program.clone()
3044            .ok_or_else(|| "net.serve_quic_fn requires a Program reference; use DefaultHandler::with_program".to_string())?;
3045        let policy = self.policy.clone();
3046        crate::quic::serve_http3_fn(port, closure, tls, program, policy, ServeOpts::from_env())
3047    }
3048
3049    #[cfg(feature = "quic")]
3050    fn dispatch_serve_quic_routed(&self, args: Vec<Value>) -> Result<Value, String> {
3051        let port = match args.first() {
3052            Some(Value::Int(n)) if (0..=65535).contains(n) => *n as u16,
3053            _ => return Err("net.serve_quic_routed(port, tls, routes, fallback): port must be Int 0..=65535".into()),
3054        };
3055        let tls = decode_tls_config(args.get(1)
3056            .ok_or_else(|| "net.serve_quic_routed(port, tls, routes, fallback): missing tls".to_string())?)?;
3057        let routes_val = args.get(2).cloned()
3058            .ok_or_else(|| "net.serve_quic_routed(port, tls, routes, fallback): missing routes".to_string())?;
3059        let fallback = match args.into_iter().nth(3) {
3060            Some(c @ Value::Closure { .. }) => c,
3061            _ => return Err("net.serve_quic_routed(port, tls, routes, fallback): fallback must be a closure".into()),
3062        };
3063        let routes = decode_routes_arg(routes_val)?;
3064        let program = self.program.clone()
3065            .ok_or_else(|| "net.serve_quic_routed requires a Program reference; use DefaultHandler::with_program".to_string())?;
3066        let policy = self.policy.clone();
3067        crate::quic::serve_http3_routed(port, routes, fallback, tls, program, policy, ServeOpts::from_env())
3068    }
3069
3070    #[cfg(not(feature = "quic"))]
3071    fn dispatch_serve_quic_named(&self, _args: Vec<Value>) -> Result<Value, String> {
3072        Err("net.serve_quic: lex-runtime was compiled without the `quic` feature (needed for quinn + h3)".into())
3073    }
3074    #[cfg(not(feature = "quic"))]
3075    fn dispatch_serve_quic_fn(&self, _args: Vec<Value>) -> Result<Value, String> {
3076        Err("net.serve_quic_fn: lex-runtime was compiled without the `quic` feature (needed for quinn + h3)".into())
3077    }
3078    #[cfg(not(feature = "quic"))]
3079    fn dispatch_serve_quic_routed(&self, _args: Vec<Value>) -> Result<Value, String> {
3080        Err("net.serve_quic_routed: lex-runtime was compiled without the `quic` feature (needed for quinn + h3)".into())
3081    }
3082}
3083
3084/// Read `LEX_NET_HTTP2` and report whether the runtime should accept
3085/// HTTP/2 connections via hyper-util's auto builder (HTTP/1 ↔ HTTP/2
3086/// preface detection). Accepts `1` / `true` (case-insensitive); anything
3087/// else (including unset) keeps the HTTP/1-only default.
3088///
3089/// h2c (cleartext HTTP/2) needs prior-knowledge clients
3090/// (`curl --http2-prior-knowledge`, wrk/h2load, gRPC). Browsers do not
3091/// speak h2c — they require ALPN over TLS, which is a separate path.
3092/// See lex-lang#488.
3093fn env_http2() -> bool {
3094    match std::env::var("LEX_NET_HTTP2") {
3095        Ok(v) => {
3096            let s = v.trim().to_ascii_lowercase();
3097            s == "1" || s == "true"
3098        }
3099        Err(_) => false,
3100    }
3101}
3102
3103/// Build a Lex request record from hyper request parts and pre-collected body bytes.
3104pub(crate) fn build_request_value_parts(
3105    parts: &hyper::http::request::Parts,
3106    body: &bytes::Bytes,
3107) -> Value {
3108    let method = parts.method.as_str().to_string();
3109    // `Uri::path()` returns just the origin-form path, regardless of
3110    // whether the wire URI was relative (`/foo` — HTTP/1.1) or
3111    // absolute (`https://host/foo` — HTTP/2 and HTTP/3 fold the
3112    // `:scheme` + `:authority` pseudo-headers into the full URI).
3113    // Reading `to_string()` would leak the scheme/authority into the
3114    // Lex handler's `req.path`, which surprised handlers built for
3115    // HTTP/1.1 (#496 surfaced this against `serve_quic`).
3116    let path = parts.uri.path().to_string();
3117    let query = parts.uri.query().map(str::to_string).unwrap_or_default();
3118    let mut headers_map = std::collections::BTreeMap::new();
3119    for (name, val) in &parts.headers {
3120        if let Ok(v) = val.to_str() {
3121            headers_map.insert(
3122                lex_bytecode::MapKey::Str(name.as_str().to_ascii_lowercase()),
3123                Value::Str(v.to_string().into()),
3124            );
3125        }
3126    }
3127    let body_str = String::from_utf8_lossy(body).into_owned();
3128    let mut rec = indexmap::IndexMap::new();
3129    rec.insert("method".into(), Value::Str(method.into()));
3130    rec.insert("path".into(), Value::Str(path.into()));
3131    rec.insert("query".into(), Value::Str(query.into()));
3132    rec.insert("body".into(), Value::Str(body_str.into()));
3133    rec.insert("headers".into(), Value::Map(headers_map));
3134    rec.insert("path_params".into(), Value::Map(std::collections::BTreeMap::new()));
3135    Value::record_dynamic(rec)
3136}
3137
3138/// Build a Lex request record from a tiny_http request (used by the TLS path).
3139fn build_request_value_tiny(req: &mut tiny_http::Request) -> Value {
3140    let method = format!("{:?}", req.method()).to_uppercase();
3141    let url = req.url().to_string();
3142    let (path, query) = match url.split_once('?') {
3143        Some((p, q)) => (p.to_string(), q.to_string()),
3144        None => (url, String::new()),
3145    };
3146    let mut headers_map = std::collections::BTreeMap::new();
3147    for h in req.headers() {
3148        headers_map.insert(
3149            lex_bytecode::MapKey::Str(h.field.as_str().as_str().to_ascii_lowercase()),
3150            Value::Str(h.value.as_str().to_string().into()),
3151        );
3152    }
3153    let mut body = String::new();
3154    let _ = req.as_reader().read_to_string(&mut body);
3155    let mut rec = indexmap::IndexMap::new();
3156    rec.insert("method".into(), Value::Str(method.into()));
3157    rec.insert("path".into(), Value::Str(path.into()));
3158    rec.insert("query".into(), Value::Str(query.into()));
3159    rec.insert("body".into(), Value::Str(body.into()));
3160    rec.insert("headers".into(), Value::Map(headers_map));
3161    rec.insert("path_params".into(), Value::Map(std::collections::BTreeMap::new()));
3162    Value::record_dynamic(rec)
3163}
3164
3165pub(crate) fn unpack_response(vm: &mut Vm, v: &Value) -> UnpackedResponse {
3166    // Accept both heap `Record` and arena `ArenaRecord` — the new
3167    // slab-direct accessors below read each uniformly without
3168    // requiring a tree-wide materialize first. See
3169    // `docs/design/arena-plumbing.md` § "Status update (2026-06-05)"
3170    // for the wire-up rationale.
3171    if !matches!(v, Value::Record { .. } | Value::ArenaRecord { .. }) {
3172        return (
3173            500,
3174            ResponseBodyOut::Str(format!("handler returned non-record: {v:?}")),
3175            vec![],
3176        );
3177    }
3178
3179    let status = vm.get_record_field(v, "status").and_then(|s| match s {
3180        Value::Int(n) => Some(n as u16),
3181        _ => None,
3182    }).unwrap_or(200);
3183
3184    // Body — read once, drain lazy iters inline so the VM is still
3185    // in scope when `materialize_lazy_iter` runs. Replaces the
3186    // previously-separate `materialize_response_body` pass.
3187    let body = match vm.get_record_field(v, "body") {
3188        Some(Value::Variant { name, mut args }) if args.len() == 1 => {
3189            let inner = args.pop().unwrap();
3190            match (name.as_str(), inner) {
3191                // Tagged ResponseBody (#375): BodyStr | BodyStream | BodyBytes.
3192                ("BodyStr", Value::Str(s)) => ResponseBodyOut::Str(s.to_string()),
3193                ("BodyStream", iter_v) => {
3194                    let drained = materialize_lazy_iter(vm, iter_v);
3195                    ResponseBodyOut::TextChunks(drain_iter_str(&drained))
3196                }
3197                ("BodyBytes", iter_v) => {
3198                    let drained = materialize_lazy_iter(vm, iter_v);
3199                    ResponseBodyOut::BytesChunks(drain_iter_bytes(&drained))
3200                }
3201                _ => ResponseBodyOut::Str(String::new()),
3202            }
3203        }
3204        // Escape hatch for handlers that don't use the nominal
3205        // `Response` alias and just return a structural record with
3206        // `body :: Str` (the pre-#375 contract). Lets internal
3207        // test handlers and one-liners keep working without
3208        // wrapping in `BodyStr(...)`.
3209        Some(Value::Str(s)) => ResponseBodyOut::Str(s.to_string()),
3210        _ => ResponseBodyOut::Str(String::new()),
3211    };
3212
3213    let headers: Vec<(String, String)> = match vm.get_record_field(v, "headers") {
3214        Some(Value::Map(hmap)) => hmap.iter().filter_map(|(k, val)| {
3215            if let (lex_bytecode::MapKey::Str(name), Value::Str(s)) = (k, val) {
3216                Some((name.clone(), s.to_string()))
3217            } else {
3218                None
3219            }
3220        }).collect(),
3221        _ => vec![],
3222    };
3223
3224    (status, body, headers)
3225}
3226
3227type HyperRespBody =
3228    http_body_util::combinators::BoxBody<bytes::Bytes, std::convert::Infallible>;
3229
3230/// Build a hyper response from the unpacked handler tuple
3231/// `(status, body, headers)`. The `unpack_response` step runs inside
3232/// the spawn_blocking closure (where `vm` is still alive) so this
3233/// function doesn't need `&Vm` — arena handles, lazy iters, and the
3234/// like are already resolved by the time we get here. Streaming
3235/// bodies (`BodyStream`, `BodyBytes`) use `ChunkedBody` which has no
3236/// known `size_hint`, so hyper emits `Transfer-Encoding: chunked` on
3237/// the wire. Plain string bodies use `Full<Bytes>` which carries
3238/// `Content-Length`.
3239fn build_hyper_response(
3240    (status, body, headers): UnpackedResponse,
3241) -> hyper::Response<HyperRespBody> {
3242    use http_body_util::BodyExt as _;
3243    let boxed_body: HyperRespBody = match body {
3244        ResponseBodyOut::Str(s) => {
3245            http_body_util::Full::new(bytes::Bytes::from(s.into_bytes())).boxed()
3246        }
3247        ResponseBodyOut::TextChunks(chunks) | ResponseBodyOut::BytesChunks(chunks) => {
3248            HyperChunkedBody::from(chunks).boxed()
3249        }
3250    };
3251    let mut builder = hyper::Response::builder().status(status);
3252    for (name, val) in headers {
3253        builder = builder.header(name, val);
3254    }
3255    builder
3256        .body(boxed_body)
3257        .unwrap_or_else(|_| error_response(500, "response build error"))
3258}
3259
3260fn error_response(status: u16, msg: &str) -> hyper::Response<HyperRespBody> {
3261    use http_body_util::BodyExt as _;
3262    hyper::Response::builder()
3263        .status(status)
3264        .body(
3265            http_body_util::Full::new(bytes::Bytes::from(msg.to_owned()))
3266                .boxed(),
3267        )
3268        .unwrap_or_else(|_| {
3269            use http_body_util::BodyExt as _;
3270            hyper::Response::new(http_body_util::Empty::new().map_err(|e| match e {}).boxed())
3271        })
3272}
3273
3274/// Async body that emits pre-collected chunks as separate HTTP frames, causing
3275/// hyper to use `Transfer-Encoding: chunked` (no `size_hint` exact count).
3276struct HyperChunkedBody {
3277    chunks: std::collections::VecDeque<Vec<u8>>,
3278}
3279
3280impl From<Vec<Vec<u8>>> for HyperChunkedBody {
3281    fn from(chunks: Vec<Vec<u8>>) -> Self {
3282        Self {
3283            chunks: chunks.into_iter().filter(|c| !c.is_empty()).collect(),
3284        }
3285    }
3286}
3287
3288impl hyper::body::Body for HyperChunkedBody {
3289    type Data = bytes::Bytes;
3290    type Error = std::convert::Infallible;
3291
3292    fn poll_frame(
3293        mut self: std::pin::Pin<&mut Self>,
3294        _cx: &mut std::task::Context<'_>,
3295    ) -> std::task::Poll<Option<Result<hyper::body::Frame<Self::Data>, Self::Error>>> {
3296        match self.chunks.pop_front() {
3297            Some(chunk) => std::task::Poll::Ready(Some(Ok(hyper::body::Frame::data(
3298                bytes::Bytes::from(chunk),
3299            )))),
3300            None => std::task::Poll::Ready(None),
3301        }
3302    }
3303}
3304
3305/// Send `body` back on a TLS `tiny_http` request. Used only by the
3306/// `net.serve_tls` path which still runs on tiny_http pending a
3307/// tokio-rustls migration.
3308fn respond_with_body_tls(
3309    req: tiny_http::Request,
3310    status: u16,
3311    body: ResponseBodyOut,
3312    headers: Vec<(String, String)>,
3313) {
3314    let tiny_headers: Vec<tiny_http::Header> = headers
3315        .into_iter()
3316        .filter_map(|(name, val)| format!("{name}: {val}").parse::<tiny_http::Header>().ok())
3317        .collect();
3318    match body {
3319        ResponseBodyOut::Str(s) => {
3320            let mut response = tiny_http::Response::from_string(s).with_status_code(status);
3321            for h in tiny_headers {
3322                response.add_header(h);
3323            }
3324            let _ = req.respond(response);
3325        }
3326        ResponseBodyOut::TextChunks(chunks) | ResponseBodyOut::BytesChunks(chunks) => {
3327            let reader = ChunkReader::new(chunks);
3328            let response = tiny_http::Response::new(
3329                tiny_http::StatusCode(status),
3330                tiny_headers,
3331                reader,
3332                None,
3333                None,
3334            );
3335            let _ = req.respond(response);
3336        }
3337    }
3338}
3339
3340/// Decoded `Response.body` (#375). The runtime emits each variant via a
3341/// different `tiny_http` path: a single `Response::from_string` for
3342/// `Str`, and a chunked-encoding `Response::new` with a `Read`-backed
3343/// chunk list for the streaming variants.
3344///
3345/// The shape `unpack_response` returns: `(status_code, body, headers)`.
3346/// Factored out as a `type` alias so call sites that store it (the
3347/// spawn_blocking closures' `Result<UnpackedResponse, ...>`) don't
3348/// trip clippy's `type_complexity` lint.
3349pub(crate) type UnpackedResponse = (u16, ResponseBodyOut, Vec<(String, String)>);
3350
3351pub(crate) enum ResponseBodyOut {
3352    Str(String),
3353    /// Pre-drained text chunks. v1 ships eager-iter only; lazy producers
3354    /// (#376 follow-up) will replace this with a Read adapter that pulls
3355    /// chunks on demand from the VM.
3356    TextChunks(Vec<Vec<u8>>),
3357    /// Pre-drained binary chunks. Each inner `Vec<u8>` is one Lex
3358    /// `List[Int]` collapsed down to a byte vector.
3359    BytesChunks(Vec<Vec<u8>>),
3360}
3361
3362/// Walk a Lex `Iter[Str]` (eager (List, Int) representation) and produce
3363/// a chunk list. The chunks are byte vectors so the chunked-Read adapter
3364/// is uniform across text and binary streams.
3365///
3366/// Iter[T] representation shifted in #376: from `Tuple([list, idx])` to
3367/// `Variant("__IterEager", [list, idx])` for the eager form. Lazy iters
3368/// produced by `iter.unfold` (`Variant("__IterLazy", [seed, step])`) and
3369/// cursor-backed iters (`Variant("__IterCursor", [handle])` from #379)
3370/// are not drained eagerly here — the v1 streaming path covers only the
3371/// eager form. Lazy/cursor producers will be wired through the
3372/// `ChunkReader` in a follow-up so each `read()` calls `iter.next` via
3373/// the VM, preserving wall-clock chunk boundaries on the wire.
3374fn drain_iter_str(v: &Value) -> Vec<Vec<u8>> {
3375    match v {
3376        Value::Variant { name, args }
3377            if name == "__IterEager" && args.len() == 2 =>
3378        {
3379            if let (Value::List(items), Value::Int(idx)) = (&args[0], &args[1]) {
3380                items.iter().skip(*idx as usize).filter_map(|item| {
3381                    if let Value::Str(s) = item { Some(s.as_bytes().to_vec()) } else { None }
3382                }).collect()
3383            } else {
3384                Vec::new()
3385            }
3386        }
3387        _ => Vec::new(),
3388    }
3389}
3390
3391/// Walk a Lex `Iter[List[Int]]` and produce a chunk list. Each `List[Int]`
3392/// element is collapsed by truncating each Int to u8 (0..=255). See
3393/// `drain_iter_str` for the lazy/cursor-iter limitation.
3394fn drain_iter_bytes(v: &Value) -> Vec<Vec<u8>> {
3395    match v {
3396        Value::Variant { name, args }
3397            if name == "__IterEager" && args.len() == 2 =>
3398        {
3399            if let (Value::List(items), Value::Int(idx)) = (&args[0], &args[1]) {
3400                items.iter().skip(*idx as usize).filter_map(|item| {
3401                    if let Value::List(ints) = item {
3402                        Some(ints.iter().filter_map(|i| match i {
3403                            Value::Int(n) => Some((*n & 0xff) as u8),
3404                            _ => None,
3405                        }).collect::<Vec<u8>>())
3406                    } else {
3407                        None
3408                    }
3409                }).collect()
3410            } else {
3411                Vec::new()
3412            }
3413        }
3414        _ => Vec::new(),
3415    }
3416}
3417
3418/// Drive an `__IterLazy(seed, step)` to exhaustion by invoking the step
3419/// closure via `vm`, then return an equivalent `__IterEager(list, 0)` so
3420/// the existing `drain_iter_*` paths can consume it.
3421///
3422/// Without this pre-pass, `BodyStream(iter.unfold(...))` produces empty
3423/// response bodies because the drain helpers match only on the eager
3424/// variant (#477). The step closure can carry effects; we ignore that
3425/// here — the handler is already running on a tokio task with the same
3426/// effect bindings, so any `[net]` / `[time]` calls inside the step
3427/// re-enter the same handler context.
3428///
3429/// `__IterEager` is returned untouched. Unknown variants pass through.
3430fn materialize_lazy_iter(vm: &mut Vm, v: Value) -> Value {
3431    let mut current = v;
3432    let mut items: Vec<Value> = Vec::new();
3433    loop {
3434        match current {
3435            Value::Variant { name, args } if name == "__IterLazy" && args.len() == 2 => {
3436                let seed = args[0].clone();
3437                let step = args[1].clone();
3438                match vm.invoke_closure_value(step.clone(), vec![seed]) {
3439                    Ok(Value::Variant { name: opt, args: opt_args })
3440                        if opt == "None" =>
3441                    {
3442                        let _ = opt_args;
3443                        break;
3444                    }
3445                    Ok(Value::Variant { name: opt, args: opt_args })
3446                        if opt == "Some" && opt_args.len() == 1 =>
3447                    {
3448                        if let Value::Tuple(pair) = &opt_args[0] {
3449                            if pair.len() == 2 {
3450                                items.push(pair[0].clone());
3451                                current = Value::Variant {
3452                                    name: "__IterLazy".to_string(),
3453                                    args: vec![pair[1].clone(), step],
3454                                };
3455                                continue;
3456                            }
3457                        }
3458                        // Malformed pair — bail to avoid infinite loop.
3459                        break;
3460                    }
3461                    _ => break,
3462                }
3463            }
3464            // Already eager (or unknown) — return as-is, possibly with
3465            // any items we collected from a partial drain.
3466            other => {
3467                if items.is_empty() {
3468                    return other;
3469                }
3470                // Mixed shape shouldn't happen in practice; fall through
3471                // to the eager builder below with the items we have.
3472                let _ = other;
3473                break;
3474            }
3475        }
3476    }
3477    Value::Variant {
3478        name: "__IterEager".to_string(),
3479        args: vec![
3480            Value::List(items.into_iter().collect()),
3481            Value::Int(0),
3482        ],
3483    }
3484}
3485
3486
3487/// `Read` adapter that returns one Lex chunk per `read()` call so
3488/// `tiny_http`'s chunked transfer-encoding emits each Lex chunk as a
3489/// distinct HTTP chunk on the wire. When the requested buffer is smaller
3490/// than the current chunk we serve a slice and keep the remainder for
3491/// the next call.
3492struct ChunkReader {
3493    chunks: std::collections::VecDeque<Vec<u8>>,
3494    cursor: usize,
3495}
3496
3497impl ChunkReader {
3498    fn new(chunks: Vec<Vec<u8>>) -> Self {
3499        Self {
3500            chunks: chunks.into_iter().filter(|c| !c.is_empty()).collect(),
3501            cursor: 0,
3502        }
3503    }
3504}
3505
3506impl std::io::Read for ChunkReader {
3507    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3508        loop {
3509            let Some(front) = self.chunks.front() else {
3510                return Ok(0);
3511            };
3512            let remaining = &front[self.cursor..];
3513            if remaining.is_empty() {
3514                self.chunks.pop_front();
3515                self.cursor = 0;
3516                continue;
3517            }
3518            let n = remaining.len().min(buf.len());
3519            buf[..n].copy_from_slice(&remaining[..n]);
3520            self.cursor += n;
3521            if self.cursor >= front.len() {
3522                self.chunks.pop_front();
3523                self.cursor = 0;
3524            }
3525            return Ok(n);
3526        }
3527    }
3528}
3529
3530/// HTTP/1.1 client backed by `ureq` + `rustls`. Accepts both
3531/// `http://` and `https://` URLs. Returns `Result[Str, Str]` as a
3532/// Lex `Value::Variant`. The earlier hand-rolled HTTP/1.0 client
3533/// was plain-TCP only — most public APIs are HTTPS, so the demo
3534/// could fetch `example.com` but not `wttr.in` or `api.github.com`.
3535fn http_request(method: &str, url: &str, body: Option<&str>) -> Value {
3536    use std::time::Duration;
3537    // ureq 3 puts 4xx/5xx behind `Error::StatusCode(code)` and consumes
3538    // the response, so the body would be lost. Disabling
3539    // `http_status_as_error` lets us check the status manually and
3540    // surface `Err("status 404: <body>")` like the old code did.
3541    let agent: ureq::Agent = ureq::Agent::config_builder()
3542        .timeout_connect(Some(Duration::from_secs(10)))
3543        .timeout_recv_body(Some(Duration::from_secs(30)))
3544        .timeout_send_body(Some(Duration::from_secs(10)))
3545        .http_status_as_error(false)
3546        .build()
3547        .into();
3548    let resp = match (method, body) {
3549        ("GET", _) => agent.get(url).call(),
3550        ("POST", Some(b)) => agent.post(url).send(b),
3551        ("POST", None) => agent.post(url).send(""),
3552        (m, _) => return err_value(format!("unsupported method: {m}")),
3553    };
3554    match resp {
3555        Ok(mut r) => {
3556            let status = r.status().as_u16();
3557            let body = r.body_mut().read_to_string().unwrap_or_default();
3558            if (200..300).contains(&status) {
3559                Value::Variant { name: "Ok".into(), args: vec![Value::Str(body.into())] }
3560            } else {
3561                err_value(format!("status {status}: {body}"))
3562            }
3563        }
3564        Err(e) => err_value(format!("transport: {e}")),
3565    }
3566}
3567
3568/// Build a ureq agent for `http.stream_lines` with a long timeout.
3569/// Local models (Ollama, vLLM) can take minutes to load before they start
3570/// responding, and thinking-heavy models can take minutes to finish.
3571/// Use timeout_global so the limit applies to the entire operation
3572/// (connect + send + recv) rather than individual phases, avoiding the
3573/// 10-second default that with_config().read_to_vec() uses for body reads.
3574fn http_stream_agent() -> ureq::Agent {
3575    use std::time::Duration;
3576    ureq::Agent::config_builder()
3577        .timeout_global(Some(Duration::from_secs(600)))
3578        .http_status_as_error(false)
3579        .build()
3580        .into()
3581}
3582
3583/// Build a ureq agent for `std.http.{send,get,post}` with the given
3584/// timeout (None → use the same defaults as the legacy `net.{get,post}`
3585/// path). Separate from `http_request` so the rich `http.send` flow
3586/// can supply per-request overrides.
3587///
3588/// When the caller supplies `timeout_ms` we apply it as a single
3589/// `timeout_global` covering the whole operation (connect + send + recv)
3590/// and drop the per-phase caps — exactly like `http_stream_agent`. A
3591/// per-phase cap (notably the bound on waiting for the *first* response
3592/// byte) would otherwise fire long before the caller's budget: a slow
3593/// first response — e.g. an LLM cold-loading a multi-GB model — then
3594/// fails at ~10s even though `timeout_ms` was set to 120000. (#646)
3595fn http_agent(timeout_ms: Option<u64>) -> ureq::Agent {
3596    use std::time::Duration;
3597    match timeout_ms {
3598        Some(ms) => ureq::Agent::config_builder()
3599            .timeout_global(Some(Duration::from_millis(ms)))
3600            .http_status_as_error(false)
3601            .build()
3602            .into(),
3603        None => ureq::Agent::config_builder()
3604            .timeout_connect(Some(Duration::from_secs(10)))
3605            .timeout_recv_body(Some(Duration::from_secs(30)))
3606            .timeout_send_body(Some(Duration::from_secs(10)))
3607            .http_status_as_error(false)
3608            .build()
3609            .into(),
3610    }
3611}
3612
3613/// Map ureq's transport error to the structured `HttpError` variant
3614/// std.http exposes to user code. Anything not specifically a
3615/// timeout / TLS error funnels into `NetworkError`.
3616fn http_error_value(e: ureq::Error) -> Value {
3617    let (ctor, payload): (&str, Option<String>) = match &e {
3618        ureq::Error::Timeout(_) => ("TimeoutError", None),
3619        ureq::Error::Tls(s) => ("TlsError", Some((*s).into())),
3620        ureq::Error::Pem(p) => ("TlsError", Some(format!("{p}"))),
3621        ureq::Error::Rustls(r) => ("TlsError", Some(format!("{r}"))),
3622        _ => ("NetworkError", Some(format!("{e}"))),
3623    };
3624    let args = match payload { Some(s) => vec![Value::Str(s.into())], None => vec![] };
3625    let inner = Value::Variant { name: ctor.into(), args };
3626    Value::Variant { name: "Err".into(), args: vec![inner] }
3627}
3628
3629fn http_decode_err(msg: String) -> Value {
3630    let inner = Value::Variant {
3631        name: "DecodeError".into(),
3632        args: vec![Value::Str(msg.into())],
3633    };
3634    Value::Variant { name: "Err".into(), args: vec![inner] }
3635}
3636
3637/// Run a request and pack the ureq response into the
3638/// `{ status, headers, body }` Lex record (or the structured
3639/// `HttpError` on failure). `headers_extra` pairs are appended to the
3640/// outgoing request after `content_type` is applied.
3641fn http_send_simple(
3642    method: &str,
3643    url: &str,
3644    body: Option<Vec<u8>>,
3645    content_type: &str,
3646    timeout_ms: Option<u64>,
3647) -> Value {
3648    http_send_full(method, url, body, content_type, &[], timeout_ms)
3649}
3650
3651fn http_send_full(
3652    method: &str,
3653    url: &str,
3654    body: Option<Vec<u8>>,
3655    content_type: &str,
3656    headers: &[(String, String)],
3657    timeout_ms: Option<u64>,
3658) -> Value {
3659    let agent = http_agent(timeout_ms);
3660    // Normalise method to uppercase before matching. Per RFC 7230, HTTP
3661    // methods are case-sensitive, but lex callers naturally write
3662    // `"put"` / `"PUT"` interchangeably; uppercasing here keeps the
3663    // surface forgiving without compromising the wire format (ureq
3664    // sends whatever method name we pass to the per-method builder).
3665    let method_upper = method.to_ascii_uppercase();
3666    let body_bytes: Vec<u8> = body.unwrap_or_default();
3667    let resp = match method_upper.as_str() {
3668        // Bodyless methods. PUT/PATCH/DELETE technically allow a body,
3669        // but in practice (and per #503's OCPI flows) DELETE is most
3670        // often bodyless; if a future caller needs DELETE-with-body
3671        // we can split it via a different ureq builder.
3672        "GET" => {
3673            let mut req = agent.get(url);
3674            if !content_type.is_empty() { req = req.header("content-type", content_type); }
3675            for (k, v) in headers { req = req.header(k.as_str(), v.as_str()); }
3676            req.call()
3677        }
3678        "HEAD" => {
3679            let mut req = agent.head(url);
3680            for (k, v) in headers { req = req.header(k.as_str(), v.as_str()); }
3681            req.call()
3682        }
3683        "DELETE" => {
3684            let mut req = agent.delete(url);
3685            for (k, v) in headers { req = req.header(k.as_str(), v.as_str()); }
3686            req.call()
3687        }
3688        // Methods that carry a request body. `body.unwrap_or_default()`
3689        // means a missing body sends an empty payload, which is the
3690        // correct default for POST `{}` style requests and matches
3691        // curl's `-X POST` (no `-d`) behaviour.
3692        "POST" => {
3693            let mut req = agent.post(url);
3694            if !content_type.is_empty() { req = req.header("content-type", content_type); }
3695            for (k, v) in headers { req = req.header(k.as_str(), v.as_str()); }
3696            req.send(&body_bytes[..])
3697        }
3698        "PUT" => {
3699            let mut req = agent.put(url);
3700            if !content_type.is_empty() { req = req.header("content-type", content_type); }
3701            for (k, v) in headers { req = req.header(k.as_str(), v.as_str()); }
3702            req.send(&body_bytes[..])
3703        }
3704        "PATCH" => {
3705            let mut req = agent.patch(url);
3706            if !content_type.is_empty() { req = req.header("content-type", content_type); }
3707            for (k, v) in headers { req = req.header(k.as_str(), v.as_str()); }
3708            req.send(&body_bytes[..])
3709        }
3710        m => {
3711            return http_decode_err(format!("unsupported method: {m}"));
3712        }
3713    };
3714    match resp {
3715        Ok(mut r) => {
3716            let status = r.status().as_u16() as i64;
3717            let headers_map = collect_response_headers(r.headers());
3718            let body_bytes = match r.body_mut().with_config().limit(10 * 1024 * 1024).read_to_vec() {
3719                Ok(b) => b,
3720                Err(e) => return http_decode_err(format!("body read: {e}")),
3721            };
3722            let mut rec = indexmap::IndexMap::new();
3723            rec.insert("status".into(), Value::Int(status));
3724            rec.insert("headers".into(), Value::Map(headers_map));
3725            rec.insert("body".into(), Value::Bytes(body_bytes));
3726            Value::Variant { name: "Ok".into(), args: vec![Value::record_dynamic(rec)] }
3727        }
3728        Err(e) => http_error_value(e),
3729    }
3730}
3731
3732fn collect_response_headers(
3733    headers: &ureq::http::HeaderMap,
3734) -> std::collections::BTreeMap<lex_bytecode::MapKey, Value> {
3735    let mut out = std::collections::BTreeMap::new();
3736    for (name, value) in headers.iter() {
3737        let v = value.to_str().unwrap_or("").to_string();
3738        out.insert(lex_bytecode::MapKey::Str(name.as_str().to_string()), Value::Str(v.into()));
3739    }
3740    out
3741}
3742
3743/// Pull the standard `HttpRequest` shape out of a `Value::Record`
3744/// and dispatch through `http_send_full`. The handler verifies
3745/// `--allow-net-host` for the URL before sending.
3746fn http_send_record(handler: &DefaultHandler, req: &indexmap::IndexMap<smol_str::SmolStr, Value>) -> Value {
3747    let method = match req.get("method") {
3748        Some(Value::Str(s)) => s.to_string(),
3749        _ => return http_decode_err("HttpRequest.method must be Str".into()),
3750    };
3751    let url = match req.get("url") {
3752        Some(Value::Str(s)) => s.to_string(),
3753        _ => return http_decode_err("HttpRequest.url must be Str".into()),
3754    };
3755    if let Err(e) = handler.ensure_host_allowed(&url) {
3756        return http_decode_err(e);
3757    }
3758    let body = match req.get("body") {
3759        Some(Value::Variant { name, args }) if name == "None" => None,
3760        Some(Value::Variant { name, args }) if name == "Some" => match args.as_slice() {
3761            [Value::Bytes(b)] => Some(b.clone()),
3762            _ => return http_decode_err("HttpRequest.body Some payload must be Bytes".into()),
3763        },
3764        _ => return http_decode_err("HttpRequest.body must be Option[Bytes]".into()),
3765    };
3766    let timeout_ms = match req.get("timeout_ms") {
3767        Some(Value::Variant { name, .. }) if name == "None" => None,
3768        Some(Value::Variant { name, args }) if name == "Some" => match args.as_slice() {
3769            [Value::Int(n)] if *n >= 0 => Some(*n as u64),
3770            _ => return http_decode_err(
3771                "HttpRequest.timeout_ms Some payload must be a non-negative Int".into()),
3772        },
3773        _ => return http_decode_err("HttpRequest.timeout_ms must be Option[Int]".into()),
3774    };
3775    let headers: Vec<(String, String)> = match req.get("headers") {
3776        Some(Value::Map(m)) => m.iter().filter_map(|(k, v)| {
3777            let kk = match k { lex_bytecode::MapKey::Str(s) => s.clone(), _ => return None };
3778            let vv = match v { Value::Str(s) => s.to_string(), _ => return None };
3779            Some((kk, vv))
3780        }).collect(),
3781        _ => return http_decode_err("HttpRequest.headers must be Map[Str, Str]".into()),
3782    };
3783    http_send_full(&method, &url, body, "", &headers, timeout_ms)
3784}
3785
3786fn expect_record(v: Option<&Value>) -> Result<&indexmap::IndexMap<smol_str::SmolStr, Value>, String> {
3787    match v {
3788        Some(Value::Record { fields: r, .. }) => Ok(r),
3789        Some(other) => Err(format!("expected Record, got {other:?}")),
3790        None => Err("missing Record argument".into()),
3791    }
3792}
3793
3794fn err_value(msg: String) -> Value {
3795    Value::Variant { name: "Err".into(), args: vec![Value::Str(msg.into())] }
3796}
3797
3798fn expect_str(v: Option<&Value>) -> Result<&str, String> {
3799    match v {
3800        Some(Value::Str(s)) => Ok(s),
3801        Some(other) => Err(format!("expected Str arg, got {other:?}")),
3802        None => Err("missing argument".into()),
3803    }
3804}
3805
3806fn expect_int(v: Option<&Value>) -> Result<i64, String> {
3807    match v {
3808        Some(Value::Int(n)) => Ok(*n),
3809        Some(other) => Err(format!("expected Int arg, got {other:?}")),
3810        None => Err("missing argument".into()),
3811    }
3812}
3813
3814fn ok(v: Value) -> Value {
3815    Value::Variant { name: "Ok".into(), args: vec![v] }
3816}
3817fn err(v: Value) -> Value {
3818    Value::Variant { name: "Err".into(), args: vec![v] }
3819}
3820
3821// Root of the process content store for std.vcs (#5). Matches the store-using
3822// CLI commands (branch/op): $LEX_STORE_ROOT override, else ~/.lex/store.
3823fn vcs_store_root() -> std::path::PathBuf {
3824    if let Ok(p) = std::env::var("LEX_STORE_ROOT") {
3825        return std::path::PathBuf::from(p);
3826    }
3827    let home = std::env::var("HOME")
3828        .map(std::path::PathBuf::from)
3829        .unwrap_or_else(|_| std::path::PathBuf::from("."));
3830    home.join(".lex/store")
3831}
3832
3833/// Streaming HTTP POST that yields the response body line-by-line as a lazy
3834/// `Stream[Str]` (#683). Intended for LLM provider APIs and other SSE/NDJSON
3835/// endpoints. Connection errors at request time → `Err(Str)`.
3836///
3837/// Truly incremental: ureq 3.3's `Body::into_reader()` gives a `BodyReader`
3838/// (impl `io::Read`), so the returned `Stream[Str]` is a lazy line iterator
3839/// directly over the socket. Each `stream.next` reads exactly the next line on
3840/// demand — an endpoint that holds the connection open and emits events over
3841/// time is consumed event-by-event instead of blocking until the server closes
3842/// (the old `read_to_vec()` path buffered the whole body and hung on open-ended
3843/// SSE). Because reads only happen when the consumer pulls, there's no extra
3844/// buffering; a mid-stream read error / close simply ends the stream.
3845fn http_stream_lines_impl(handler: &DefaultHandler, url: &str, headers_val: &Value, body: &str) -> Value {
3846    let body_bytes = body.as_bytes().to_vec();
3847    // 10-minute body-read timeout — local models (Ollama, vLLM) can take
3848    // several minutes between events on long thinking traces.
3849    let agent = http_stream_agent();
3850    let mut req = agent.post(url);
3851    if let Value::Map(headers) = headers_val {
3852        for (k, v) in headers {
3853            let key_str = match k {
3854                lex_bytecode::MapKey::Str(s) => s.as_str(),
3855                _ => continue,
3856            };
3857            if let Value::Str(val) = v {
3858                req = req.header(key_str, val.as_str());
3859            }
3860        }
3861    }
3862    match req.send(&body_bytes[..]) {
3863        Ok(resp) => {
3864            use std::io::BufRead;
3865            let reader = std::io::BufReader::new(resp.into_body().into_reader());
3866            // Lazy: `map_while(ok)` stops at the first read error / EOF; the
3867            // \uXXXX un-escaping preserves the pre-#683 decoded-text contract.
3868            let lines = reader
3869                .lines()
3870                .map_while(Result::ok)
3871                .map(|l| decode_unicode_escapes(&l));
3872            let handle = handler.register_stream(lines);
3873            ok(stream_handle_value(handle))
3874        }
3875        Err(e) => err(Value::Str(format!("http.stream_lines: {e}").into())),
3876    }
3877}
3878
3879fn decode_unicode_escapes(s: &str) -> String {
3880    let mut result = String::with_capacity(s.len());
3881    let mut chars = s.chars().peekable();
3882    while let Some(c) = chars.next() {
3883        if c != '\\' {
3884            result.push(c);
3885            continue;
3886        }
3887        match chars.peek() {
3888            Some('u') => {
3889                chars.next();
3890                let hex: String = (0..4).filter_map(|_| chars.next()).collect();
3891                if hex.len() == 4 {
3892                    if let Ok(n) = u32::from_str_radix(&hex, 16) {
3893                        if let Some(ch) = char::from_u32(n) {
3894                            result.push(ch);
3895                            continue;
3896                        }
3897                    }
3898                }
3899                result.push('\\');
3900                result.push('u');
3901                result.push_str(&hex);
3902            }
3903            _ => result.push(c),
3904        }
3905    }
3906    result
3907}
3908
3909/// Build a `SqlError = { message, code, detail }` Lex record (#380).
3910/// `code` and `detail` are `None` by default; the driver-specific
3911/// converters below populate them with real values.
3912fn sql_error(message: impl Into<String>, code: Option<String>, detail: Option<String>) -> Value {
3913    let some = |s: String| Value::Variant { name: "Some".into(), args: vec![Value::Str(s.into())] };
3914    let none = || Value::Variant { name: "None".into(), args: vec![] };
3915    let mut rec = indexmap::IndexMap::new();
3916    let msg: String = message.into();
3917    rec.insert("message".into(), Value::Str(msg.into()));
3918    rec.insert("code".into(), match code {
3919        Some(c) => some(c),
3920        None => none(),
3921    });
3922    rec.insert("detail".into(), match detail {
3923        Some(d) => some(d),
3924        None => none(),
3925    });
3926    Value::record_dynamic(rec)
3927}
3928
3929/// Convert a rusqlite error into a `SqlError`. The `code` is the
3930/// symbolic extended-result-code name (`SQLITE_BUSY`,
3931/// `SQLITE_CONSTRAINT_UNIQUE`, …) when present — this is what
3932/// callers want for dialect-aware retry / conflict handling.
3933///
3934/// rusqlite has two main error shapes that carry a numeric code:
3935/// `SqliteFailure` (driver-side runtime errors — constraints, busy,
3936/// IO) and `SqlInputError` (statement-preparation failures —
3937/// syntax, unknown table). Both are unpacked the same way.
3938fn sqlite_err_to_sql_error(e: rusqlite::Error, op: &str) -> Value {
3939    let message = format!("{op}: {e}");
3940    match &e {
3941        rusqlite::Error::SqliteFailure(ffi, detail_opt) => {
3942            sql_error(
3943                message,
3944                Some(sqlite_extended_code_name(ffi.extended_code)),
3945                detail_opt.clone(),
3946            )
3947        }
3948        rusqlite::Error::SqlInputError { error, msg, .. } => {
3949            sql_error(
3950                message,
3951                Some(sqlite_extended_code_name(error.extended_code)),
3952                Some(msg.clone()),
3953            )
3954        }
3955        _ => sql_error(message, None, None),
3956    }
3957}
3958
3959/// Map a SQLite extended result code (numeric) to its symbolic name.
3960/// We only cover the codes a Lex caller is likely to dispatch on
3961/// (constraint kinds, busy/locked, read-only, IO); anything else
3962/// falls back to a generic `SQLITE_ERROR_<n>` stringification so the
3963/// numeric code is still recoverable.
3964fn sqlite_extended_code_name(code: i32) -> String {
3965    use rusqlite::ffi::*;
3966    let s = match code {
3967        SQLITE_BUSY => "SQLITE_BUSY",
3968        SQLITE_LOCKED => "SQLITE_LOCKED",
3969        SQLITE_READONLY => "SQLITE_READONLY",
3970        SQLITE_IOERR => "SQLITE_IOERR",
3971        SQLITE_CORRUPT => "SQLITE_CORRUPT",
3972        SQLITE_NOTFOUND => "SQLITE_NOTFOUND",
3973        SQLITE_FULL => "SQLITE_FULL",
3974        SQLITE_CANTOPEN => "SQLITE_CANTOPEN",
3975        SQLITE_PROTOCOL => "SQLITE_PROTOCOL",
3976        SQLITE_SCHEMA => "SQLITE_SCHEMA",
3977        SQLITE_TOOBIG => "SQLITE_TOOBIG",
3978        SQLITE_CONSTRAINT => "SQLITE_CONSTRAINT",
3979        SQLITE_CONSTRAINT_CHECK => "SQLITE_CONSTRAINT_CHECK",
3980        SQLITE_CONSTRAINT_FOREIGNKEY => "SQLITE_CONSTRAINT_FOREIGNKEY",
3981        SQLITE_CONSTRAINT_NOTNULL => "SQLITE_CONSTRAINT_NOTNULL",
3982        SQLITE_CONSTRAINT_PRIMARYKEY => "SQLITE_CONSTRAINT_PRIMARYKEY",
3983        SQLITE_CONSTRAINT_TRIGGER => "SQLITE_CONSTRAINT_TRIGGER",
3984        SQLITE_CONSTRAINT_UNIQUE => "SQLITE_CONSTRAINT_UNIQUE",
3985        SQLITE_CONSTRAINT_VTAB => "SQLITE_CONSTRAINT_VTAB",
3986        SQLITE_CONSTRAINT_ROWID => "SQLITE_CONSTRAINT_ROWID",
3987        SQLITE_MISMATCH => "SQLITE_MISMATCH",
3988        SQLITE_RANGE => "SQLITE_RANGE",
3989        SQLITE_NOTADB => "SQLITE_NOTADB",
3990        SQLITE_AUTH => "SQLITE_AUTH",
3991        _ => return format!("SQLITE_ERROR_{code}"),
3992    };
3993    s.to_string()
3994}
3995
3996/// Convert a postgres error into a `SqlError`. The `code` is the
3997/// 5-character SQLSTATE (`23505`, `40P01`, …); `detail` is the
3998/// driver's optional detail message when present.
3999fn pg_err_to_sql_error(e: postgres::Error, op: &str) -> Value {
4000    let message = format!("{op}: {e}");
4001    let code = e.as_db_error().map(|db| db.code().code().to_string());
4002    let detail = e.as_db_error().and_then(|db| db.detail().map(|s| s.to_string()));
4003    sql_error(message, code, detail)
4004}
4005
4006impl DefaultHandler {
4007    /// Implementation of `agent.call_mcp(server, tool, args_json)`.
4008    /// Goes through the LRU client cache (#197): the named server
4009    /// is spawned on first use and reused on subsequent calls.
4010    /// On failure the offending client is dropped so the next
4011    /// call respawns rather than silently failing forever.
4012    fn dispatch_call_mcp(&mut self, args: Vec<Value>) -> Value {
4013        let server = match args.first() {
4014            Some(Value::Str(s)) => s.clone(),
4015            _ => return err(Value::Str(
4016                "agent.call_mcp(server, tool, args_json): server must be Str".into())),
4017        };
4018        let tool = match args.get(1) {
4019            Some(Value::Str(s)) => s.clone(),
4020            _ => return err(Value::Str(
4021                "agent.call_mcp(server, tool, args_json): tool must be Str".into())),
4022        };
4023        let args_json = match args.get(2) {
4024            Some(Value::Str(s)) => s.clone(),
4025            _ => return err(Value::Str(
4026                "agent.call_mcp(server, tool, args_json): args_json must be Str".into())),
4027        };
4028        let parsed: serde_json::Value = match serde_json::from_str(&args_json) {
4029            Ok(v) => v,
4030            Err(e) => return err(Value::Str(format!(
4031                "agent.call_mcp: args_json is not valid JSON: {e}").into())),
4032        };
4033        match self.mcp_clients.call(&server, &tool, parsed) {
4034            Ok(result) => ok(Value::Str(
4035                serde_json::to_string(&result).unwrap_or_else(|_| "null".into()).into())),
4036            Err(e) => err(Value::Str(e.into())),
4037        }
4038    }
4039
4040    /// Implementation of `agent.cloud_stream(prompt) -> Result[Stream[Str], Str]`
4041    /// (#305 slice 3). The fixture path (`LEX_LLM_STREAM_FIXTURE`)
4042    /// splits the env-var value on `|` and yields each segment as
4043    /// one chunk; it's the load-bearing test hook. Live HTTP
4044    /// chunked-response support is deferred to a follow-up slice.
4045    fn dispatch_cloud_stream(&mut self, args: Vec<Value>) -> Value {
4046        let _prompt = match args.first() {
4047            Some(Value::Str(s)) => s.clone(),
4048            _ => return err(Value::Str(
4049                "agent.cloud_stream(prompt): prompt must be Str".into())),
4050        };
4051        let chunks: Vec<String> = match std::env::var("LEX_LLM_STREAM_FIXTURE") {
4052            Ok(v) => v.split('|').map(|s| s.to_string()).collect(),
4053            Err(_) => return err(Value::Str(
4054                "agent.cloud_stream: live streaming not yet implemented; \
4055                 set LEX_LLM_STREAM_FIXTURE='chunk1|chunk2|…' for tests".into())),
4056        };
4057        let handle = self.register_stream(chunks.into_iter());
4058        ok(stream_handle_value(handle))
4059    }
4060
4061    /// Implementation of `stream.next(s) -> Option[T]` (#305 slice 3).
4062    /// Returns `Some(chunk)` for each producer yield and `None` once
4063    /// the producer is exhausted. Unknown handle ids return `None`
4064    /// rather than erroring so streams can be safely consumed past
4065    /// the end (matches the semantics of `Iterator::next`).
4066    fn dispatch_stream_next(&mut self, args: Vec<Value>) -> Value {
4067        let handle = match args.first().and_then(stream_handle_id) {
4068            Some(h) => h,
4069            None => return Value::Variant { name: "None".into(), args: vec![] },
4070        };
4071        let mut streams = match self.streams.lock() {
4072            Ok(g) => g,
4073            Err(_) => return Value::Variant { name: "None".into(), args: vec![] },
4074        };
4075        match streams.get_mut(&handle).and_then(|it| it.next()) {
4076            Some(chunk) => some(Value::Str(chunk.into())),
4077            None => {
4078                streams.remove(&handle);
4079                Value::Variant { name: "None".into(), args: vec![] }
4080            }
4081        }
4082    }
4083
4084    /// Implementation of `stream.collect(s) -> List[T]` (#305 slice 3).
4085    /// Drains the producer eagerly. Unknown handles drain to an
4086    /// empty list so the contract is `collect ∘ collect = []`
4087    /// (idempotent on a closed stream).
4088    fn dispatch_stream_collect(&mut self, args: Vec<Value>) -> Value {
4089        let handle = match args.first().and_then(stream_handle_id) {
4090            Some(h) => h,
4091            None => return Value::List(std::collections::VecDeque::new()),
4092        };
4093        let mut iter = {
4094            let mut streams = match self.streams.lock() {
4095                Ok(g) => g,
4096                Err(_) => return Value::List(std::collections::VecDeque::new()),
4097            };
4098            match streams.remove(&handle) {
4099                Some(it) => it,
4100                None => return Value::List(std::collections::VecDeque::new()),
4101            }
4102        };
4103        let mut out: std::collections::VecDeque<Value> = std::collections::VecDeque::new();
4104        for chunk in iter.by_ref() {
4105            out.push_back(Value::Str(chunk.into()));
4106        }
4107        Value::List(out)
4108    }
4109
4110    /// Register a producer iterator and return its handle id. The
4111    /// handle is monotonic-counter-based so two streams created in
4112    /// quick succession get distinct ids.
4113    fn register_stream<I>(&self, iter: I) -> String
4114    where
4115        I: Iterator<Item = String> + Send + 'static,
4116    {
4117        let id = self
4118            .next_stream_id
4119            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4120        let handle = format!("stream_{id}");
4121        if let Ok(mut streams) = self.streams.lock() {
4122            streams.insert(handle.clone(), Box::new(iter));
4123        }
4124        handle
4125    }
4126}
4127
4128/// Build the runtime representation of a `Stream[T]` value:
4129/// `Variant("__StreamHandle", [Str(handle_id)])`. The opaque tag is
4130/// prefixed with `__` so it can't collide with a user-declared
4131/// variant.
4132fn stream_handle_value(handle: String) -> Value {
4133    Value::Variant {
4134        name: "__StreamHandle".into(),
4135        args: vec![Value::Str(handle.into())],
4136    }
4137}
4138
4139/// Inverse of [`stream_handle_value`] — extract the handle id from
4140/// a Stream value, or `None` if the input doesn't have the
4141/// expected shape.
4142fn stream_handle_id(v: &Value) -> Option<String> {
4143    match v {
4144        Value::Variant { name, args } if name == "__StreamHandle" => match args.first() {
4145            Some(Value::Str(h)) => Some(h.to_string()),
4146            _ => None,
4147        },
4148        _ => None,
4149    }
4150}
4151
4152/// Implementation of `agent.local_complete(prompt)` (#196).
4153/// Hits Ollama (or any compatible HTTP service via `OLLAMA_HOST`)
4154/// and returns the completion text. Override at the
4155/// `EffectHandler` layer if you need a different transport.
4156fn dispatch_llm_local(args: Vec<Value>) -> Value {
4157    let prompt = match args.first() {
4158        Some(Value::Str(s)) => s.clone(),
4159        _ => return err(Value::Str(
4160            "agent.local_complete(prompt): prompt must be Str".into())),
4161    };
4162    match crate::llm::local_complete(&prompt) {
4163        Ok(text) => ok(Value::Str(text.into())),
4164        Err(e) => err(Value::Str(e.into())),
4165    }
4166}
4167
4168/// Implementation of `agent.cloud_complete(prompt)` (#196).
4169/// Hits OpenAI's chat-completions API (or any compatible
4170/// service via `OPENAI_BASE_URL`) and returns the assistant
4171/// message. Requires `OPENAI_API_KEY`. Override at the
4172/// `EffectHandler` layer for custom auth, batching, or other
4173/// providers.
4174fn dispatch_llm_cloud(args: Vec<Value>) -> Value {
4175    let prompt = match args.first() {
4176        Some(Value::Str(s)) => s.clone(),
4177        _ => return err(Value::Str(
4178            "agent.cloud_complete(prompt): prompt must be Str".into())),
4179    };
4180    match crate::llm::cloud_complete(&prompt) {
4181        Ok(text) => ok(Value::Str(text.into())),
4182        Err(e) => err(Value::Str(e.into())),
4183    }
4184}
4185
4186fn some(v: Value) -> Value {
4187    Value::Variant { name: "Some".into(), args: vec![v] }
4188}
4189fn none() -> Value {
4190    Value::Variant { name: "None".into(), args: vec![] }
4191}
4192
4193fn expect_bytes(v: Option<&Value>) -> Result<&Vec<u8>, String> {
4194    match v {
4195        Some(Value::Bytes(b)) => Ok(b),
4196        Some(other) => Err(format!("expected Bytes arg, got {other:?}")),
4197        None => Err("missing argument".into()),
4198    }
4199}
4200
4201fn expect_kv_handle(v: Option<&Value>) -> Result<u64, String> {
4202    match v {
4203        Some(Value::Int(n)) if *n >= 0 => Ok(*n as u64),
4204        Some(other) => Err(format!("expected Kv handle (Int), got {other:?}")),
4205        None => Err("missing Kv argument".into()),
4206    }
4207}
4208
4209fn expect_sql_handle(v: Option<&Value>) -> Result<u64, String> {
4210    match v {
4211        Some(Value::Int(n)) if *n >= 0 => Ok(*n as u64),
4212        Some(other) => Err(format!("expected Db handle (Int), got {other:?}")),
4213        None => Err("missing Db argument".into()),
4214    }
4215}
4216
4217#[allow(dead_code)]
4218fn expect_str_list(v: Option<&Value>) -> Result<Vec<String>, String> {
4219    match v {
4220        Some(Value::List(items)) => items.iter().map(|x| match x {
4221            Value::Str(s) => Ok(s.to_string()),
4222            other => Err(format!("expected List[Str] element, got {other:?}")),
4223        }).collect(),
4224        Some(other) => Err(format!("expected List[Str], got {other:?}")),
4225        None => Err("missing List[Str] argument".into()),
4226    }
4227}
4228
4229/// Convert a `List[SqlParam]` value to driver-neutral `SqlParamValue`s.
4230/// SqlParam = PStr(Str) | PInt(Int) | PFloat(Float) | PBool(Bool) | PNull
4231fn expect_sql_params(v: Option<&Value>) -> Result<Vec<SqlParamValue>, String> {
4232    let items = match v {
4233        Some(Value::List(xs)) => xs,
4234        Some(other) => return Err(format!("expected List[SqlParam], got {other:?}")),
4235        None => return Err("missing params argument".into()),
4236    };
4237    items.iter().map(|item| {
4238        match item {
4239            Value::Variant { name, args } => match name.as_str() {
4240                "PStr"   => match args.first() {
4241                    Some(Value::Str(s)) => Ok(SqlParamValue::Text(s.to_string())),
4242                    _ => Err("PStr requires a Str argument".into()),
4243                },
4244                "PInt"   => match args.first() {
4245                    Some(Value::Int(n)) => Ok(SqlParamValue::Integer(*n)),
4246                    _ => Err("PInt requires an Int argument".into()),
4247                },
4248                "PFloat" => match args.first() {
4249                    Some(Value::Float(f)) => Ok(SqlParamValue::Real(*f)),
4250                    _ => Err("PFloat requires a Float argument".into()),
4251                },
4252                "PBool"  => match args.first() {
4253                    Some(Value::Bool(b)) => Ok(SqlParamValue::Bool(*b)),
4254                    _ => Err("PBool requires a Bool argument".into()),
4255                },
4256                "PNull"  => Ok(SqlParamValue::Null),
4257                other    => Err(format!("unknown SqlParam constructor `{other}`")),
4258            },
4259            // Backward-compat: bare strings are accepted as PStr.
4260            Value::Str(s) => Ok(SqlParamValue::Text(s.to_string())),
4261            other => Err(format!("expected SqlParam variant, got {other:?}")),
4262        }
4263    }).collect()
4264}
4265
4266/// Convert `SqlParamValue`s to rusqlite-typed values for SQLite binding.
4267fn sqlite_params(params: &[SqlParamValue]) -> Vec<rusqlite::types::Value> {
4268    params.iter().map(|p| match p {
4269        SqlParamValue::Text(s)    => rusqlite::types::Value::Text(s.clone()),
4270        SqlParamValue::Integer(n) => rusqlite::types::Value::Integer(*n),
4271        SqlParamValue::Real(f)    => rusqlite::types::Value::Real(*f),
4272        SqlParamValue::Bool(b)    => rusqlite::types::Value::Integer(*b as i64),
4273        SqlParamValue::Null       => rusqlite::types::Value::Null,
4274    }).collect()
4275}
4276
4277/// Lex SQL is authored with SQLite-style `?` positional placeholders, but
4278/// Postgres requires `$1, $2, …`. Rewrite each `?` placeholder to the matching
4279/// `$n` so the same parameterized statement runs on both backends. Only `?`
4280/// outside single-quoted string literals are treated as placeholders (a `?`
4281/// inside a literal — or an escaped `''` — is left untouched).
4282fn pg_rewrite_placeholders(sql: &str) -> String {
4283    let mut out = String::with_capacity(sql.len() + 8);
4284    let mut n: u32 = 0;
4285    let mut in_str = false;
4286    let mut chars = sql.chars().peekable();
4287    while let Some(c) = chars.next() {
4288        match c {
4289            '\'' => {
4290                out.push(c);
4291                if in_str {
4292                    // A doubled '' is an escaped quote: stay inside the literal.
4293                    if chars.peek() == Some(&'\'') {
4294                        out.push(chars.next().unwrap());
4295                    } else {
4296                        in_str = false;
4297                    }
4298                } else {
4299                    in_str = true;
4300                }
4301            }
4302            '?' if !in_str => {
4303                n += 1;
4304                out.push('$');
4305                out.push_str(&n.to_string());
4306            }
4307            _ => out.push(c),
4308        }
4309    }
4310    out
4311}
4312
4313#[cfg(test)]
4314mod pg_placeholder_tests {
4315    use super::pg_rewrite_placeholders;
4316
4317    #[test]
4318    fn rewrites_positional_placeholders() {
4319        assert_eq!(
4320            pg_rewrite_placeholders(
4321                "INSERT INTO events(id, kind, parent, payload_json, ts_ms) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING"
4322            ),
4323            "INSERT INTO events(id, kind, parent, payload_json, ts_ms) VALUES ($1, $2, $3, $4, $5) ON CONFLICT(id) DO NOTHING"
4324        );
4325        assert_eq!(
4326            pg_rewrite_placeholders("SELECT * FROM t WHERE a=? AND b=?"),
4327            "SELECT * FROM t WHERE a=$1 AND b=$2"
4328        );
4329    }
4330
4331    #[test]
4332    fn leaves_question_marks_inside_string_literals() {
4333        assert_eq!(
4334            pg_rewrite_placeholders("INSERT INTO t VALUES (?, 'lit?', ?)"),
4335            "INSERT INTO t VALUES ($1, 'lit?', $2)"
4336        );
4337    }
4338
4339    #[test]
4340    fn handles_escaped_quotes_in_literals() {
4341        assert_eq!(
4342            pg_rewrite_placeholders("UPDATE t SET note='it''s ok?' WHERE id=?"),
4343            "UPDATE t SET note='it''s ok?' WHERE id=$1"
4344        );
4345    }
4346
4347    #[test]
4348    fn no_placeholders_is_unchanged() {
4349        assert_eq!(pg_rewrite_placeholders("SELECT 1"), "SELECT 1");
4350    }
4351}
4352
4353/// Lex's `PFloat` params are always `f64`, but a placeholder's Postgres
4354/// parameter type is inferred from the column it binds to, and Lex SQL
4355/// schemas commonly use `REAL` (float4) rather than `DOUBLE PRECISION`
4356/// (float8). A plain `f64` only implements `ToSql` for float8, so binding
4357/// it against a float4 parameter fails to serialize. This wrapper accepts
4358/// either width and encodes to whichever one Postgres actually asked for.
4359#[derive(Debug)]
4360struct PgFloatParam(f64);
4361
4362impl postgres::types::ToSql for PgFloatParam {
4363    fn to_sql(
4364        &self,
4365        ty: &postgres::types::Type,
4366        out: &mut bytes::BytesMut,
4367    ) -> Result<postgres::types::IsNull, Box<dyn std::error::Error + Sync + Send>> {
4368        use bytes::BufMut;
4369        match *ty {
4370            postgres::types::Type::FLOAT4 => out.put_f32(self.0 as f32),
4371            _ => out.put_f64(self.0),
4372        }
4373        Ok(postgres::types::IsNull::No)
4374    }
4375
4376    fn accepts(ty: &postgres::types::Type) -> bool {
4377        matches!(*ty, postgres::types::Type::FLOAT4 | postgres::types::Type::FLOAT8)
4378    }
4379
4380    postgres::types::to_sql_checked!();
4381}
4382
4383/// Box `SqlParamValue`s as `dyn ToSql + Sync` for Postgres binding.
4384fn pg_param_refs(params: &[SqlParamValue]) -> Vec<Box<dyn postgres::types::ToSql + Sync>> {
4385    params.iter().map(|p| -> Box<dyn postgres::types::ToSql + Sync> {
4386        match p {
4387            SqlParamValue::Text(s)    => Box::new(s.clone()),
4388            SqlParamValue::Integer(n) => Box::new(*n),
4389            SqlParamValue::Real(f)    => Box::new(PgFloatParam(*f)),
4390            SqlParamValue::Bool(b)    => Box::new(*b),
4391            SqlParamValue::Null       => Box::new(Option::<String>::None),
4392        }
4393    }).collect()
4394}
4395
4396#[cfg(test)]
4397mod pg_float_param_tests {
4398    use super::PgFloatParam;
4399    use bytes::{Buf, BytesMut};
4400    use postgres::types::{ToSql, Type};
4401
4402    #[test]
4403    fn encodes_float4_as_4_bytes_matching_the_value() {
4404        let mut out = BytesMut::new();
4405        PgFloatParam(6.5).to_sql(&Type::FLOAT4, &mut out).unwrap();
4406        assert_eq!(out.len(), 4);
4407        assert_eq!(out.get_f32(), 6.5f32);
4408    }
4409
4410    #[test]
4411    fn encodes_float8_as_8_bytes_matching_the_value() {
4412        let mut out = BytesMut::new();
4413        PgFloatParam(6.5).to_sql(&Type::FLOAT8, &mut out).unwrap();
4414        assert_eq!(out.len(), 8);
4415        assert_eq!(out.get_f64(), 6.5f64);
4416    }
4417
4418    #[test]
4419    fn accepts_only_float4_and_float8() {
4420        assert!(PgFloatParam::accepts(&Type::FLOAT4));
4421        assert!(PgFloatParam::accepts(&Type::FLOAT8));
4422        assert!(!PgFloatParam::accepts(&Type::TEXT));
4423        assert!(!PgFloatParam::accepts(&Type::INT8));
4424    }
4425}
4426
4427/// Run a statement on SQLite and pack rows into `Value::List(Value::Record(...))`.
4428fn sql_run_query_sqlite(
4429    conn: &rusqlite::Connection,
4430    stmt_str: &str,
4431    params: &[SqlParamValue],
4432) -> Value {
4433    let mut stmt = match conn.prepare(stmt_str) {
4434        Ok(s)  => s,
4435        Err(e) => return err(sqlite_err_to_sql_error(e, "sql.query")),
4436    };
4437    let column_count = stmt.column_count();
4438    let column_names: Vec<String> = (0..column_count)
4439        .map(|i| stmt.column_name(i).unwrap_or("").to_string())
4440        .collect();
4441    let bound = sqlite_params(params);
4442    let bind: Vec<&dyn rusqlite::ToSql> = bound.iter()
4443        .map(|p| p as &dyn rusqlite::ToSql)
4444        .collect();
4445    let mut rows = match stmt.query(rusqlite::params_from_iter(bind.iter())) {
4446        Ok(r)  => r,
4447        Err(e) => return err(sqlite_err_to_sql_error(e, "sql.query")),
4448    };
4449    let mut out: Vec<Value> = Vec::new();
4450    loop {
4451        let row = match rows.next() {
4452            Ok(Some(r)) => r,
4453            Ok(None)    => break,
4454            Err(e)      => return err(sqlite_err_to_sql_error(e, "sql.query")),
4455        };
4456        let mut rec = indexmap::IndexMap::new();
4457        for (i, name) in column_names.iter().enumerate() {
4458            let cell = match row.get_ref(i) {
4459                Ok(c)  => sql_value_ref_to_lex(c),
4460                Err(e) => return err(sqlite_err_to_sql_error(e, &format!("sql.query: column {i}"))),
4461            };
4462            rec.insert(name.clone(), cell);
4463        }
4464        out.push(Value::record_dynamic(rec));
4465    }
4466    ok(Value::List(out.into()))
4467}
4468
4469/// Run a statement on Postgres and pack rows into `Value::List(Value::Record(...))`.
4470fn sql_run_query_pg(
4471    client: &mut postgres::Client,
4472    stmt_str: &str,
4473    params: &[SqlParamValue],
4474) -> Value {
4475    let pg = pg_param_refs(params);
4476    let refs: Vec<&(dyn postgres::types::ToSql + Sync)> =
4477        pg.iter().map(|b| b.as_ref()).collect();
4478    let stmt_pg = pg_rewrite_placeholders(stmt_str);
4479    let rows = match client.query(stmt_pg.as_str(), &refs) {
4480        Ok(r)  => r,
4481        Err(e) => return err(pg_err_to_sql_error(e, "sql.query")),
4482    };
4483    let out: std::collections::VecDeque<Value> = rows.iter().map(|row| {
4484        Value::record_dynamic(pg_row_to_lex_record(row))
4485    }).collect();
4486    ok(Value::List(out))
4487}
4488
4489/// Convert a Postgres row to a Lex record, mapping column types to Lex values.
4490fn pg_row_to_lex_record(row: &postgres::Row) -> indexmap::IndexMap<String, Value> {
4491    use postgres::types::Type;
4492    let mut rec = indexmap::IndexMap::new();
4493    for (i, col) in row.columns().iter().enumerate() {
4494        let ty = col.type_();
4495        let val = if *ty == Type::INT2 || *ty == Type::INT4 || *ty == Type::INT8 {
4496            row.get::<_, Option<i64>>(i).map(Value::Int).unwrap_or(Value::Unit)
4497        } else if *ty == Type::FLOAT4 {
4498            row.get::<_, Option<f32>>(i).map(|f| Value::Float(f as f64)).unwrap_or(Value::Unit)
4499        } else if *ty == Type::FLOAT8 {
4500            row.get::<_, Option<f64>>(i).map(Value::Float).unwrap_or(Value::Unit)
4501        } else if *ty == Type::BOOL {
4502            row.get::<_, Option<bool>>(i).map(Value::Bool).unwrap_or(Value::Unit)
4503        } else if *ty == Type::BYTEA {
4504            row.get::<_, Option<Vec<u8>>>(i).map(Value::Bytes).unwrap_or(Value::Unit)
4505        } else {
4506            row.get::<_, Option<String>>(i).map(|s| Value::Str(s.into())).unwrap_or(Value::Unit)
4507        };
4508        rec.insert(col.name().to_string(), val);
4509    }
4510    rec
4511}
4512
4513/// Extract a column value from a row record by name, returning `Option[X]`.
4514fn sql_get_col<F>(args: &[Value], convert: F) -> Result<Value, String>
4515where
4516    F: Fn(&Value) -> Option<Value>,
4517{
4518    let row = args.first().ok_or("sql.get_*: missing row argument")?;
4519    let col = match args.get(1) {
4520        Some(Value::Str(s)) => s.as_str(),
4521        Some(other) => return Err(format!("sql.get_*: column name must be Str, got {other:?}")),
4522        None => return Err("sql.get_*: missing column name argument".into()),
4523    };
4524    let cell = match row {
4525        Value::Record { fields: rec, .. } => rec.get(col).cloned(),
4526        other => return Err(format!("sql.get_*: row must be a Record, got {other:?}")),
4527    };
4528    Ok(match cell.and_then(|v| convert(&v)) {
4529        Some(v) => Value::Variant { name: "Some".into(), args: vec![v] },
4530        None    => Value::Variant { name: "None".into(), args: vec![] },
4531    })
4532}
4533
4534fn sql_value_ref_to_lex(v: rusqlite::types::ValueRef<'_>) -> Value {
4535    use rusqlite::types::ValueRef;
4536    match v {
4537        ValueRef::Null       => Value::Unit,
4538        ValueRef::Integer(n) => Value::Int(n),
4539        ValueRef::Real(f)    => Value::Float(f),
4540        ValueRef::Text(s)    => Value::Str(String::from_utf8_lossy(s).into_owned().into()),
4541        ValueRef::Blob(b)    => Value::Bytes(b.to_vec()),
4542    }
4543}
4544
4545// -- log state (process-wide; configurable via log.set_*) --
4546
4547#[derive(Clone, Copy, PartialEq, PartialOrd)]
4548enum LogLevel { Debug, Info, Warn, Error }
4549
4550#[derive(Clone, Copy, PartialEq)]
4551enum LogFormat { Text, Json }
4552
4553#[derive(Clone)]
4554enum LogSink {
4555    Stderr,
4556    File(std::sync::Arc<Mutex<std::fs::File>>),
4557}
4558
4559struct LogState {
4560    level: LogLevel,
4561    format: LogFormat,
4562    sink: LogSink,
4563}
4564
4565fn log_state() -> &'static Mutex<LogState> {
4566    static STATE: OnceLock<Mutex<LogState>> = OnceLock::new();
4567    STATE.get_or_init(|| Mutex::new(LogState {
4568        level: LogLevel::Info,
4569        format: LogFormat::Text,
4570        sink: LogSink::Stderr,
4571    }))
4572}
4573
4574fn parse_log_level(s: &str) -> Option<LogLevel> {
4575    match s {
4576        "debug" => Some(LogLevel::Debug),
4577        "info" => Some(LogLevel::Info),
4578        "warn" => Some(LogLevel::Warn),
4579        "error" => Some(LogLevel::Error),
4580        _ => None,
4581    }
4582}
4583
4584fn level_label(l: LogLevel) -> &'static str {
4585    match l {
4586        LogLevel::Debug => "debug",
4587        LogLevel::Info => "info",
4588        LogLevel::Warn => "warn",
4589        LogLevel::Error => "error",
4590    }
4591}
4592
4593fn emit_log(level: LogLevel, msg: &str) {
4594    let state = log_state().lock().unwrap();
4595    if level < state.level {
4596        return;
4597    }
4598    let ts = chrono::Utc::now().to_rfc3339();
4599    let line = match state.format {
4600        LogFormat::Text => format!("[{}] {}: {}\n", ts, level_label(level), msg),
4601        LogFormat::Json => {
4602            // Hand-rolled JSON to avoid pulling serde_json into the
4603            // hot path; msg gets minimal escaping (the four common
4604            // cases that break a JSON line).
4605            let escaped = msg
4606                .replace('\\', "\\\\")
4607                .replace('"',  "\\\"")
4608                .replace('\n', "\\n")
4609                .replace('\r', "\\r");
4610            format!(
4611                "{{\"ts\":\"{ts}\",\"level\":\"{}\",\"msg\":\"{escaped}\"}}\n",
4612                level_label(level),
4613            )
4614        }
4615    };
4616    let sink = state.sink.clone();
4617    drop(state);
4618    match sink {
4619        LogSink::Stderr => {
4620            use std::io::Write;
4621            let _ = std::io::stderr().write_all(line.as_bytes());
4622        }
4623        LogSink::File(f) => {
4624            use std::io::Write;
4625            if let Ok(mut g) = f.lock() {
4626                let _ = g.write_all(line.as_bytes());
4627            }
4628        }
4629    }
4630}
4631
4632pub(crate) struct ProcessState {
4633    child: std::process::Child,
4634    stdout: Option<std::io::BufReader<std::process::ChildStdout>>,
4635    stderr: Option<std::io::BufReader<std::process::ChildStderr>>,
4636}
4637
4638/// Process-wide registry of live `process.spawn` handles. Capped at
4639/// [`MAX_PROCESS_HANDLES`] to bound long-running programs that spawn
4640/// many short-lived children: on each `spawn` past the cap, the
4641/// least-recently-used entry is dropped (which `Drop`s its
4642/// `ProcessState`, leaving the child orphaned but the registry
4643/// bounded). `process.wait` also drops the entry on completion since
4644/// the handle becomes terminal once the child exits.
4645///
4646/// Each entry is wrapped in `Arc<Mutex<ProcessState>>` so the global
4647/// lookup mutex is held only briefly during dispatch — once we have
4648/// the per-handle `Arc`, the global lock is released and the slow
4649/// op (`wait`, `read_*_line`) only contends on its own handle's
4650/// mutex. Reads on different handles no longer block each other.
4651fn process_registry() -> &'static Mutex<ProcessRegistry> {
4652    static REGISTRY: OnceLock<Mutex<ProcessRegistry>> = OnceLock::new();
4653    REGISTRY.get_or_init(|| Mutex::new(ProcessRegistry::with_capacity(MAX_PROCESS_HANDLES)))
4654}
4655
4656const MAX_PROCESS_HANDLES: usize = 256;
4657
4658type SharedProcessState = Arc<Mutex<ProcessState>>;
4659
4660pub(crate) struct ProcessRegistry {
4661    entries: indexmap::IndexMap<u64, SharedProcessState>,
4662    cap: usize,
4663}
4664
4665impl ProcessRegistry {
4666    pub(crate) fn with_capacity(cap: usize) -> Self {
4667        Self { entries: indexmap::IndexMap::new(), cap }
4668    }
4669
4670    /// Insert a freshly-spawned child. If at cap, evict the LRU entry
4671    /// first; the dropped `ProcessState`'s child stays alive (orphaned)
4672    /// but its file descriptors are released.
4673    pub(crate) fn insert(&mut self, handle: u64, state: ProcessState) {
4674        if self.entries.len() >= self.cap {
4675            self.entries.shift_remove_index(0);
4676        }
4677        self.entries.insert(handle, Arc::new(Mutex::new(state)));
4678    }
4679
4680    /// Look up a handle, marking it most-recently-used on hit. Returns
4681    /// a clone of the shared `Arc` — callers should release the global
4682    /// registry lock before locking the per-handle mutex.
4683    pub(crate) fn touch_get(&mut self, handle: u64) -> Option<SharedProcessState> {
4684        let idx = self.entries.get_index_of(&handle)?;
4685        self.entries.move_index(idx, self.entries.len() - 1);
4686        self.entries.get(&handle).cloned()
4687    }
4688
4689    /// Drop the registry entry. The underlying `Arc` may outlive the
4690    /// removal if another op still holds it; that's intentional — the
4691    /// in-flight op finishes against the existing `ProcessState`, and
4692    /// only fresh lookups start failing.
4693    pub(crate) fn remove(&mut self, handle: u64) {
4694        self.entries.shift_remove(&handle);
4695    }
4696
4697    #[cfg(test)]
4698    pub(crate) fn len(&self) -> usize { self.entries.len() }
4699}
4700
4701fn next_process_handle() -> u64 {
4702    static COUNTER: AtomicU64 = AtomicU64::new(1);
4703    COUNTER.fetch_add(1, Ordering::SeqCst)
4704}
4705
4706#[cfg(all(test, unix))]
4707mod process_registry_tests {
4708    use super::{ProcessRegistry, ProcessState};
4709
4710    /// Spawn a trivial short-lived child for use as registry payload.
4711    /// `true` exits immediately — we don't actually run the child for
4712    /// real, we just need a valid `std::process::Child`.
4713    fn fresh_state() -> ProcessState {
4714        let child = std::process::Command::new("true")
4715            .stdout(std::process::Stdio::null())
4716            .stderr(std::process::Stdio::null())
4717            .spawn()
4718            .expect("spawn `true`");
4719        ProcessState { child, stdout: None, stderr: None }
4720    }
4721
4722    #[test]
4723    fn insert_and_get_round_trip() {
4724        let mut r = ProcessRegistry::with_capacity(4);
4725        r.insert(1, fresh_state());
4726        assert!(r.touch_get(1).is_some());
4727        assert!(r.touch_get(2).is_none());
4728    }
4729
4730    #[test]
4731    fn touch_get_returns_distinct_arcs_for_distinct_handles() {
4732        let mut r = ProcessRegistry::with_capacity(4);
4733        r.insert(1, fresh_state());
4734        r.insert(2, fresh_state());
4735        let a = r.touch_get(1).unwrap();
4736        let b = r.touch_get(2).unwrap();
4737        // Different Arcs — pointer-equality check.
4738        assert!(!std::sync::Arc::ptr_eq(&a, &b));
4739    }
4740
4741    #[test]
4742    fn cap_evicts_lru_on_overflow() {
4743        let mut r = ProcessRegistry::with_capacity(2);
4744        r.insert(1, fresh_state());
4745        r.insert(2, fresh_state());
4746        let _ = r.touch_get(1);
4747        r.insert(3, fresh_state());
4748        assert!(r.touch_get(1).is_some(), "1 was MRU, should survive");
4749        assert!(r.touch_get(2).is_none(), "2 was LRU, should be evicted");
4750        assert!(r.touch_get(3).is_some(), "3 just inserted, should survive");
4751        assert_eq!(r.len(), 2);
4752    }
4753
4754    #[test]
4755    fn cap_with_no_touches_evicts_in_insertion_order() {
4756        let mut r = ProcessRegistry::with_capacity(2);
4757        r.insert(10, fresh_state());
4758        r.insert(20, fresh_state());
4759        r.insert(30, fresh_state());
4760        assert!(r.touch_get(10).is_none());
4761        assert!(r.touch_get(20).is_some());
4762        assert!(r.touch_get(30).is_some());
4763    }
4764
4765    #[test]
4766    fn remove_drops_entry() {
4767        let mut r = ProcessRegistry::with_capacity(4);
4768        r.insert(1, fresh_state());
4769        r.remove(1);
4770        assert!(r.touch_get(1).is_none());
4771        assert_eq!(r.len(), 0);
4772    }
4773
4774    #[test]
4775    fn many_inserts_stay_bounded_at_cap() {
4776        let cap = 8;
4777        let mut r = ProcessRegistry::with_capacity(cap);
4778        for i in 0..(cap as u64 * 3) {
4779            r.insert(i, fresh_state());
4780            assert!(r.len() <= cap);
4781        }
4782        assert_eq!(r.len(), cap);
4783    }
4784
4785    #[test]
4786    fn outstanding_arc_outlives_remove() {
4787        // Holding the per-handle Arc while another op removes the
4788        // entry must not invalidate the in-flight op. Mirrors the
4789        // wait-completes-then-removes pattern.
4790        let mut r = ProcessRegistry::with_capacity(4);
4791        r.insert(1, fresh_state());
4792        let arc = r.touch_get(1).expect("entry exists");
4793        r.remove(1);
4794        // Registry forgot about it, but the Arc still works.
4795        assert!(r.touch_get(1).is_none());
4796        let _state = arc.lock().unwrap();
4797    }
4798}
4799
4800fn expect_process_handle(v: Option<&Value>) -> Result<u64, String> {
4801    match v {
4802        Some(Value::Int(n)) if *n >= 0 => Ok(*n as u64),
4803        Some(other) => Err(format!("expected ProcessHandle (Int), got {other:?}")),
4804        None => Err("missing ProcessHandle argument".into()),
4805    }
4806}
4807
4808/// Process-wide registry of open `Kv` handles. Each `kv.open` allocates
4809/// a new u64 handle via [`next_kv_handle`] and stores the `sled::Db`
4810/// here; subsequent ops fetch by handle. `kv.close` removes the entry.
4811///
4812/// Capped at [`MAX_KV_HANDLES`] to prevent leaks from long-running
4813/// programs that open many short-lived stores without calling
4814/// `kv.close`. On insert at cap, the least-recently-used entry is
4815/// dropped (closing its `sled::Db`); subsequent ops on the evicted
4816/// handle return the standard "closed or unknown Kv handle" error.
4817/// Any access (`get`, `put`, `delete`, `contains`, `list_prefix`)
4818/// touches the LRU order.
4819fn kv_registry() -> &'static Mutex<KvRegistry> {
4820    static REGISTRY: OnceLock<Mutex<KvRegistry>> = OnceLock::new();
4821    REGISTRY.get_or_init(|| Mutex::new(KvRegistry::with_capacity(MAX_KV_HANDLES)))
4822}
4823
4824/// Maximum number of `kv.open` handles kept alive at once. Past this
4825/// cap, the least-recently-used handle is evicted on each new open.
4826/// Sized so that pathological "open and forget" programs are bounded
4827/// without breaking real-world programs that intentionally keep one or
4828/// two long-lived stores open.
4829const MAX_KV_HANDLES: usize = 256;
4830
4831/// LRU-bounded set of open `sled::Db` instances keyed by `u64` handle.
4832/// Built on `IndexMap` for O(1) insert / remove / lookup with
4833/// insertion-order traversal — touching an entry just shift-moves it
4834/// to the back, evictions pop from the front.
4835pub(crate) struct KvRegistry {
4836    entries: indexmap::IndexMap<u64, sled::Db>,
4837    cap: usize,
4838}
4839
4840impl KvRegistry {
4841    pub(crate) fn with_capacity(cap: usize) -> Self {
4842        Self { entries: indexmap::IndexMap::new(), cap }
4843    }
4844
4845    /// Insert a freshly-opened db. If we're already at cap, evict the
4846    /// LRU entry first; the dropped `sled::Db` closes its files.
4847    pub(crate) fn insert(&mut self, handle: u64, db: sled::Db) {
4848        if self.entries.len() >= self.cap {
4849            self.entries.shift_remove_index(0);
4850        }
4851        self.entries.insert(handle, db);
4852    }
4853
4854    /// Look up a handle, marking it most-recently-used on hit.
4855    pub(crate) fn touch_get(&mut self, handle: u64) -> Option<&sled::Db> {
4856        let idx = self.entries.get_index_of(&handle)?;
4857        self.entries.move_index(idx, self.entries.len() - 1);
4858        self.entries.get(&handle)
4859    }
4860
4861    /// Explicit `kv.close`: drop the handle if present.
4862    pub(crate) fn remove(&mut self, handle: u64) {
4863        self.entries.shift_remove(&handle);
4864    }
4865
4866    #[cfg(test)]
4867    pub(crate) fn len(&self) -> usize { self.entries.len() }
4868}
4869
4870fn next_kv_handle() -> u64 {
4871    static COUNTER: AtomicU64 = AtomicU64::new(1);
4872    COUNTER.fetch_add(1, Ordering::SeqCst)
4873}
4874
4875// ── std.redis registry (#533) ────────────────────────────────────────
4876//
4877// `ConnRedis` is an opaque Int handle into `RedisRegistry`. Each
4878// `redis.connect` allocates a new handle via `next_redis_handle` and
4879// stores the open `redis::Connection` plus the original URL (needed to
4880// open dedicated pub/sub connections for `subscribe`/`psubscribe`).
4881//
4882// LRU-bounded at MAX_REDIS_HANDLES to avoid leaks from programs that
4883// open many short-lived connections without calling `redis.close`.
4884
4885/// Per-handle state: the live synchronous connection and the URL it
4886/// was opened from. The URL is kept so `subscribe`/`psubscribe` can
4887/// open a fresh dedicated connection (Redis forbids non-Pub/Sub
4888/// commands on a subscribed connection).
4889struct RedisEntry {
4890    url: String,
4891    conn: redis::Connection,
4892}
4893
4894struct RedisRegistry {
4895    entries: indexmap::IndexMap<u64, RedisEntry>,
4896    cap: usize,
4897}
4898
4899impl RedisRegistry {
4900    fn with_capacity(cap: usize) -> Self {
4901        Self { entries: indexmap::IndexMap::new(), cap }
4902    }
4903
4904    fn insert(&mut self, handle: u64, entry: RedisEntry) {
4905        if self.entries.len() >= self.cap {
4906            self.entries.shift_remove_index(0);
4907        }
4908        self.entries.insert(handle, entry);
4909    }
4910
4911    fn touch_get_mut(&mut self, handle: u64) -> Option<&mut RedisEntry> {
4912        let idx = self.entries.get_index_of(&handle)?;
4913        self.entries.move_index(idx, self.entries.len() - 1);
4914        self.entries.get_mut(&handle)
4915    }
4916
4917    /// Return the URL for a handle without touching LRU order. Used by
4918    /// `subscribe`/`psubscribe` to open a dedicated connection.
4919    fn get_url(&self, handle: u64) -> Option<String> {
4920        self.entries.get(&handle).map(|e| e.url.clone())
4921    }
4922
4923    fn remove(&mut self, handle: u64) {
4924        self.entries.shift_remove(&handle);
4925    }
4926}
4927
4928fn redis_registry() -> &'static Mutex<RedisRegistry> {
4929    static REGISTRY: OnceLock<Mutex<RedisRegistry>> = OnceLock::new();
4930    REGISTRY.get_or_init(|| Mutex::new(RedisRegistry::with_capacity(MAX_REDIS_HANDLES)))
4931}
4932
4933const MAX_REDIS_HANDLES: usize = 256;
4934
4935fn next_redis_handle() -> u64 {
4936    static COUNTER: AtomicU64 = AtomicU64::new(1);
4937    COUNTER.fetch_add(1, Ordering::SeqCst)
4938}
4939
4940fn expect_redis_handle(v: Option<&Value>) -> Result<u64, String> {
4941    match v {
4942        Some(Value::Int(n)) if *n >= 0 => Ok(*n as u64),
4943        Some(other) => Err(format!("expected ConnRedis (Int), got {other:?}")),
4944        None => Err("missing ConnRedis argument".into()),
4945    }
4946}
4947
4948/// Process-wide registry of open `Db` handles. Same shape as the kv
4949/// and process registries: per-handle `Arc<Mutex<…>>` so dispatch
4950/// only briefly holds the global lock and ops on different
4951/// connections don't serialize. LRU-bounded at
4952/// [`MAX_SQL_HANDLES`] to avoid leaks from long-running programs
4953/// that open many short-lived databases.
4954fn sql_registry() -> &'static Mutex<SqlRegistry> {
4955    static REGISTRY: OnceLock<Mutex<SqlRegistry>> = OnceLock::new();
4956    REGISTRY.get_or_init(|| Mutex::new(SqlRegistry::with_capacity(MAX_SQL_HANDLES)))
4957}
4958
4959const MAX_SQL_HANDLES: usize = 256;
4960
4961// ── Streaming cursors (#379) ─────────────────────────────────────────
4962//
4963// `sql.query_iter[T]` opens a *server-side* cursor and returns an
4964// `Iter[T]` backed by a producer thread streaming rows through a
4965// bounded mpsc channel. The bytecode `iter.next` op dispatches on the
4966// `__IterCursor(handle)` variant tag and effect-calls
4967// `sql.cursor_next(handle)` to pull one row at a time.
4968//
4969// Producer-thread semantics: while the cursor is live, the producer
4970// holds the underlying SQL connection's `Arc<Mutex<SqlConn>>` lock.
4971// Other ops on the same Db handle block until the cursor is drained
4972// or evicted. This matches every server-side cursor protocol
4973// (sqlite's `sqlite3_step`, Postgres `DECLARE/FETCH`) — neither
4974// driver supports concurrent statements on a single connection.
4975//
4976// Channel capacity: 64 rows. Producer blocks at 64-row backlog,
4977// keeping resident memory bounded regardless of result-set size.
4978// Consumer disconnect (Receiver dropped) causes the next send to
4979// fail, the producer exits, drops the prepared statement, and
4980// releases the SqlConn lock — so closing a cursor is just "stop
4981// calling next and let the receiver go out of scope."
4982
4983const CURSOR_CHANNEL_CAPACITY: usize = 64;
4984const MAX_CURSOR_HANDLES: usize = 256;
4985
4986type CursorReceiver = std::sync::mpsc::Receiver<Result<Value, String>>;
4987
4988pub(crate) struct CursorRegistry {
4989    /// Each cursor's receiver lives behind its own Mutex so multiple
4990    /// `sql.cursor_next` calls on the same cursor serialize correctly.
4991    /// The outer `Arc` lets the global registry lock be released
4992    /// before blocking on `recv()`.
4993    entries: indexmap::IndexMap<u64, Arc<Mutex<CursorReceiver>>>,
4994    cap: usize,
4995}
4996
4997impl CursorRegistry {
4998    pub(crate) fn with_capacity(cap: usize) -> Self {
4999        Self { entries: indexmap::IndexMap::new(), cap }
5000    }
5001
5002    pub(crate) fn insert(&mut self, handle: u64, rx: CursorReceiver) {
5003        if self.entries.len() >= self.cap {
5004            self.entries.shift_remove_index(0);
5005        }
5006        self.entries.insert(handle, Arc::new(Mutex::new(rx)));
5007    }
5008
5009    pub(crate) fn touch_get(&mut self, handle: u64) -> Option<Arc<Mutex<CursorReceiver>>> {
5010        let idx = self.entries.get_index_of(&handle)?;
5011        self.entries.move_index(idx, self.entries.len() - 1);
5012        self.entries.get(&handle).cloned()
5013    }
5014
5015    pub(crate) fn remove(&mut self, handle: u64) {
5016        self.entries.shift_remove(&handle);
5017    }
5018}
5019
5020fn cursor_registry() -> &'static Mutex<CursorRegistry> {
5021    static REGISTRY: OnceLock<Mutex<CursorRegistry>> = OnceLock::new();
5022    REGISTRY.get_or_init(|| Mutex::new(CursorRegistry::with_capacity(MAX_CURSOR_HANDLES)))
5023}
5024
5025fn next_cursor_handle() -> u64 {
5026    static COUNTER: AtomicU64 = AtomicU64::new(1);
5027    COUNTER.fetch_add(1, Ordering::SeqCst)
5028}
5029
5030/// SQLite cursor producer: locks the conn, prepares the statement,
5031/// walks rows, ships each to the consumer through `sender`. Exits on
5032/// row exhaustion, consumer disconnect, or first error. The lock is
5033/// released when the thread function returns (statement dropped first
5034/// to satisfy rusqlite's borrow).
5035fn sqlite_cursor_producer(
5036    conn_arc: Arc<Mutex<SqlConn>>,
5037    stmt_str: String,
5038    params: Vec<SqlParamValue>,
5039    sender: std::sync::mpsc::SyncSender<Result<Value, String>>,
5040) {
5041    let mut conn_guard = match conn_arc.lock() {
5042        Ok(g) => g,
5043        Err(p) => p.into_inner(),
5044    };
5045    let SqlConn::Sqlite(c) = &mut *conn_guard else {
5046        let _ = sender.send(Err("sqlite_cursor_producer called on non-sqlite conn".into()));
5047        return;
5048    };
5049    let mut stmt = match c.prepare(&stmt_str) {
5050        Ok(s) => s,
5051        Err(e) => { let _ = sender.send(Err(format!("prepare: {e}"))); return; }
5052    };
5053    let column_count = stmt.column_count();
5054    let column_names: Vec<String> = (0..column_count)
5055        .map(|i| stmt.column_name(i).unwrap_or("").to_string())
5056        .collect();
5057    let bound = sqlite_params(&params);
5058    let bind: Vec<&dyn rusqlite::ToSql> =
5059        bound.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
5060    let mut rows = match stmt.query(rusqlite::params_from_iter(bind.iter())) {
5061        Ok(r) => r,
5062        Err(e) => { let _ = sender.send(Err(format!("query: {e}"))); return; }
5063    };
5064    loop {
5065        match rows.next() {
5066            Ok(None) => break,
5067            Err(e) => {
5068                let _ = sender.send(Err(format!("row: {e}")));
5069                break;
5070            }
5071            Ok(Some(row)) => {
5072                let mut rec = indexmap::IndexMap::new();
5073                for (i, name) in column_names.iter().enumerate() {
5074                    let val = match row.get_ref(i) {
5075                        Ok(vr) => sql_value_ref_to_lex(vr),
5076                        Err(_) => Value::Unit,
5077                    };
5078                    rec.insert(name.clone(), val);
5079                }
5080                if sender.send(Ok(Value::record_dynamic(rec))).is_err() {
5081                    break;
5082                }
5083            }
5084        }
5085    }
5086}
5087
5088/// Postgres cursor producer: opens a transaction + named cursor,
5089/// fetches rows in batches, ships each one through `sender`. Closes
5090/// the cursor and commits the transaction on exit.
5091fn pg_cursor_producer(
5092    conn_arc: Arc<Mutex<SqlConn>>,
5093    stmt_str: String,
5094    params: Vec<SqlParamValue>,
5095    sender: std::sync::mpsc::SyncSender<Result<Value, String>>,
5096) {
5097    let mut conn_guard = match conn_arc.lock() {
5098        Ok(g) => g,
5099        Err(p) => p.into_inner(),
5100    };
5101    let SqlConn::Postgres(c) = &mut *conn_guard else {
5102        let _ = sender.send(Err("pg_cursor_producer called on non-postgres conn".into()));
5103        return;
5104    };
5105    let pg = pg_param_refs(&params);
5106    let refs: Vec<&(dyn postgres::types::ToSql + Sync)> =
5107        pg.iter().map(|b| b.as_ref()).collect();
5108    let mut tx = match c.transaction() {
5109        Ok(t) => t,
5110        Err(e) => { let _ = sender.send(Err(format!("begin: {e}"))); return; }
5111    };
5112    // Use a uniquely-named cursor so concurrent producers on
5113    // distinct Db handles don't collide on the cursor namespace.
5114    let stmt_str = pg_rewrite_placeholders(&stmt_str);
5115    let cur_name = format!("__lex_cur_{}", next_cursor_handle());
5116    if let Err(e) = tx.execute(
5117        &format!("DECLARE \"{cur_name}\" NO SCROLL CURSOR FOR {stmt_str}"),
5118        &refs,
5119    ) {
5120        let _ = sender.send(Err(format!("declare: {e}")));
5121        return;
5122    }
5123    let fetch_sql = format!("FETCH 64 FROM \"{cur_name}\"");
5124    'outer: loop {
5125        let batch = match tx.query(&fetch_sql, &[]) {
5126            Ok(r) => r,
5127            Err(e) => { let _ = sender.send(Err(format!("fetch: {e}"))); break; }
5128        };
5129        if batch.is_empty() {
5130            break;
5131        }
5132        for row in batch.iter() {
5133            let rec = pg_row_to_lex_record(row);
5134            if sender.send(Ok(Value::record_dynamic(rec))).is_err() {
5135                break 'outer;
5136            }
5137        }
5138    }
5139    let _ = tx.execute(&format!("CLOSE \"{cur_name}\""), &[]);
5140    let _ = tx.commit();
5141}
5142
5143/// Driver-neutral SQL parameter value shared between SQLite and Postgres paths.
5144#[derive(Debug, Clone)]
5145enum SqlParamValue {
5146    Text(String),
5147    Integer(i64),
5148    Real(f64),
5149    Bool(bool),
5150    Null,
5151}
5152
5153/// Abstraction over a SQLite connection or a Postgres client.
5154pub(crate) enum SqlConn {
5155    Sqlite(rusqlite::Connection),
5156    Postgres(postgres::Client),
5157}
5158
5159type SharedConn = Arc<Mutex<SqlConn>>;
5160
5161pub(crate) struct SqlRegistry {
5162    entries: indexmap::IndexMap<u64, SharedConn>,
5163    cap: usize,
5164}
5165
5166impl SqlRegistry {
5167    pub(crate) fn with_capacity(cap: usize) -> Self {
5168        Self { entries: indexmap::IndexMap::new(), cap }
5169    }
5170
5171    pub(crate) fn insert(&mut self, handle: u64, conn: SqlConn) {
5172        if self.entries.len() >= self.cap {
5173            self.entries.shift_remove_index(0);
5174        }
5175        self.entries.insert(handle, Arc::new(Mutex::new(conn)));
5176    }
5177
5178    /// Look up a handle, marking it MRU on hit. Returns a clone of
5179    /// the shared `Arc` so callers release the global registry
5180    /// lock before locking the per-handle mutex.
5181    pub(crate) fn touch_get(&mut self, handle: u64) -> Option<SharedConn> {
5182        let idx = self.entries.get_index_of(&handle)?;
5183        self.entries.move_index(idx, self.entries.len() - 1);
5184        self.entries.get(&handle).cloned()
5185    }
5186
5187    pub(crate) fn remove(&mut self, handle: u64) {
5188        self.entries.shift_remove(&handle);
5189    }
5190
5191    #[cfg(test)]
5192    pub(crate) fn len(&self) -> usize { self.entries.len() }
5193}
5194
5195fn next_sql_handle() -> u64 {
5196    static COUNTER: AtomicU64 = AtomicU64::new(1);
5197    COUNTER.fetch_add(1, Ordering::SeqCst)
5198}
5199
5200#[cfg(test)]
5201mod sql_registry_tests {
5202    use super::{SqlConn, SqlRegistry};
5203
5204    fn fresh() -> SqlConn {
5205        SqlConn::Sqlite(rusqlite::Connection::open_in_memory().expect("open in-memory sqlite"))
5206    }
5207
5208    #[test]
5209    fn insert_and_get_round_trip() {
5210        let mut r = SqlRegistry::with_capacity(4);
5211        r.insert(1, fresh());
5212        assert!(r.touch_get(1).is_some());
5213        assert!(r.touch_get(2).is_none());
5214    }
5215
5216    #[test]
5217    fn cap_evicts_lru_on_overflow() {
5218        let mut r = SqlRegistry::with_capacity(2);
5219        r.insert(1, fresh());
5220        r.insert(2, fresh());
5221        let _ = r.touch_get(1);
5222        r.insert(3, fresh());
5223        assert!(r.touch_get(1).is_some(), "1 was MRU, should survive");
5224        assert!(r.touch_get(2).is_none(), "2 was LRU, should be evicted");
5225        assert!(r.touch_get(3).is_some(), "3 just inserted");
5226        assert_eq!(r.len(), 2);
5227    }
5228
5229    #[test]
5230    fn remove_drops_entry() {
5231        let mut r = SqlRegistry::with_capacity(4);
5232        r.insert(1, fresh());
5233        r.remove(1);
5234        assert!(r.touch_get(1).is_none());
5235        assert_eq!(r.len(), 0);
5236    }
5237
5238    #[test]
5239    fn many_inserts_stay_bounded_at_cap() {
5240        let cap = 8;
5241        let mut r = SqlRegistry::with_capacity(cap);
5242        for i in 0..(cap as u64 * 3) {
5243            r.insert(i, fresh());
5244            assert!(r.len() <= cap);
5245        }
5246        assert_eq!(r.len(), cap);
5247    }
5248}
5249
5250#[cfg(test)]
5251mod kv_registry_tests {
5252    use super::KvRegistry;
5253
5254    /// Spin up an isolated `sled::Db` in a temp dir. Each call gets a
5255    /// unique path so concurrent tests don't collide on the lockfile.
5256    fn fresh_db(tag: &str) -> sled::Db {
5257        let dir = std::env::temp_dir().join(format!(
5258            "lex-kv-reg-{}-{}-{}",
5259            std::process::id(),
5260            tag,
5261            std::time::SystemTime::now()
5262                .duration_since(std::time::UNIX_EPOCH)
5263                .unwrap()
5264                .as_nanos()
5265        ));
5266        sled::open(&dir).expect("sled open")
5267    }
5268
5269    #[test]
5270    fn insert_and_get_round_trip() {
5271        let mut r = KvRegistry::with_capacity(4);
5272        r.insert(1, fresh_db("a"));
5273        assert!(r.touch_get(1).is_some());
5274        assert!(r.touch_get(2).is_none());
5275    }
5276
5277    #[test]
5278    fn cap_evicts_lru_on_overflow() {
5279        // cap=2: insert 1, 2; touch 1 (now MRU); insert 3 → 2 evicted.
5280        let mut r = KvRegistry::with_capacity(2);
5281        r.insert(1, fresh_db("c1"));
5282        r.insert(2, fresh_db("c2"));
5283        let _ = r.touch_get(1);
5284        r.insert(3, fresh_db("c3"));
5285        assert!(r.touch_get(1).is_some(), "1 was MRU, should survive");
5286        assert!(r.touch_get(2).is_none(), "2 was LRU, should be evicted");
5287        assert!(r.touch_get(3).is_some(), "3 just inserted, should survive");
5288        assert_eq!(r.len(), 2);
5289    }
5290
5291    #[test]
5292    fn cap_with_no_touches_evicts_in_insertion_order() {
5293        // cap=2: insert 1, 2, 3 with no touches → 1 evicted (FIFO).
5294        let mut r = KvRegistry::with_capacity(2);
5295        r.insert(10, fresh_db("f1"));
5296        r.insert(20, fresh_db("f2"));
5297        r.insert(30, fresh_db("f3"));
5298        assert!(r.touch_get(10).is_none());
5299        assert!(r.touch_get(20).is_some());
5300        assert!(r.touch_get(30).is_some());
5301    }
5302
5303    #[test]
5304    fn remove_drops_entry() {
5305        let mut r = KvRegistry::with_capacity(4);
5306        r.insert(1, fresh_db("r1"));
5307        r.remove(1);
5308        assert!(r.touch_get(1).is_none());
5309        assert_eq!(r.len(), 0);
5310    }
5311
5312    #[test]
5313    fn remove_unknown_handle_is_noop() {
5314        let mut r = KvRegistry::with_capacity(4);
5315        r.insert(1, fresh_db("u1"));
5316        r.remove(999);
5317        assert!(r.touch_get(1).is_some());
5318    }
5319
5320    #[test]
5321    fn many_inserts_stay_bounded_at_cap() {
5322        // Exhaust the cap to confirm the registry never grows past it,
5323        // even under sustained churn.
5324        let cap = 8;
5325        let mut r = KvRegistry::with_capacity(cap);
5326        for i in 0..(cap as u64 * 3) {
5327            r.insert(i, fresh_db(&format!("b{i}")));
5328            assert!(r.len() <= cap);
5329        }
5330        assert_eq!(r.len(), cap);
5331    }
5332}
5333
5334/// #463 slab-direct wire-up — locally-runnable coverage for
5335/// `unpack_response`'s arena path. The lex-runtime integration tests
5336/// (`tests/std_http.rs` etc.) overflow the dev-container disk per
5337/// `arena-plumbing.md`, so CI is the only place they run end-to-end;
5338/// these focused tests give us a local regression gate on the
5339/// boundary code itself.
5340#[cfg(test)]
5341mod unpack_response_tests {
5342    use super::*;
5343    use std::sync::Arc;
5344    use indexmap::IndexMap;
5345    use lex_bytecode::{Const, Op, Program, Value};
5346    use lex_bytecode::program::{Function, ZERO_BODY_HASH};
5347    use lex_bytecode::vm::Vm;
5348
5349    /// Build a single-fn `Program` whose body produces an
5350    /// `AllocArenaRecord`-backed `Response { status, body }`. The
5351    /// constants table holds the field names, the body variant name,
5352    /// the response text, and the status code.
5353    fn build_arena_response_program() -> Arc<Program> {
5354        let constants = vec![
5355            Const::FieldName("status".into()), // 0
5356            Const::FieldName("body".into()),   // 1
5357            Const::Int(200),                   // 2
5358            Const::VariantName("BodyStr".into()), // 3
5359            Const::Str("hello".into()),        // 4
5360        ];
5361        let mut function_names = IndexMap::new();
5362        function_names.insert("handler".to_string(), 0);
5363        Arc::new(Program {
5364            constants,
5365            functions: vec![Function {
5366                name: "handler".into(),
5367                arity: 0,
5368                locals_count: 0,
5369                code: vec![
5370                    Op::PushConst(2),                                       // 200
5371                    Op::PushConst(4),                                       // "hello"
5372                    Op::MakeVariant { name_idx: 3, arity: 1 },              // BodyStr("hello")
5373                    Op::AllocArenaRecord { shape_idx: 0, field_count: 2 },  // { status, body }
5374                    Op::Return,
5375                ],
5376                effects: vec![],
5377                body_hash: ZERO_BODY_HASH,
5378                refinements: vec![],
5379                field_ic_sites: 0,
5380            }],
5381            function_names,
5382            module_aliases: IndexMap::new(),
5383            entry: Some(0),
5384            record_shapes: vec![vec![0, 1]], // {status, body}
5385        })
5386    }
5387
5388    /// The happy path: arena handle goes in, the unpacked tuple comes
5389    /// out, no `materialize_arena_handles` walk in between. The
5390    /// boundary call site no longer holds a heap `Value::Record` —
5391    /// `unpack_response` reads straight out of the slab via
5392    /// `Vm::get_record_field`.
5393    #[test]
5394    fn unpack_response_reads_arena_record_via_slab() {
5395        let p = build_arena_response_program();
5396        let mut vm = Vm::new(&p);
5397        let scope = vm.enter_request_scope();
5398
5399        let resp = vm.invoke(p.function_names["handler"], vec![]).unwrap();
5400        // Test precondition — without this the slab-direct path isn't
5401        // being exercised at all.
5402        assert!(matches!(resp, Value::ArenaRecord { .. }),
5403            "expected ArenaRecord (slab path), got {resp:?}");
5404
5405        let (status, body, headers) = unpack_response(&mut vm, &resp);
5406        vm.exit_request_scope(scope);
5407
5408        assert_eq!(status, 200);
5409        assert!(headers.is_empty());
5410        match body {
5411            ResponseBodyOut::Str(s) => assert_eq!(s, "hello"),
5412            _ => panic!("expected BodyStr"),
5413        }
5414    }
5415
5416    /// Heap path uniformity: a handler that returns a plain
5417    /// `Value::Record` (no arena scope, or a non-arena-lowered site)
5418    /// produces the same tuple. The same `unpack_response` is the
5419    /// single chokepoint.
5420    #[test]
5421    fn unpack_response_reads_heap_record() {
5422        let p = build_arena_response_program();
5423        let mut vm = Vm::new(&p);
5424
5425        // No scope — `AllocArenaRecord` falls back to heap `MakeRecord`.
5426        let resp = vm.invoke(p.function_names["handler"], vec![]).unwrap();
5427        assert!(matches!(resp, Value::Record { .. }),
5428            "expected heap Record (fallback path), got {resp:?}");
5429
5430        let (status, body, headers) = unpack_response(&mut vm, &resp);
5431        assert_eq!(status, 200);
5432        assert!(headers.is_empty());
5433        match body {
5434            ResponseBodyOut::Str(s) => assert_eq!(s, "hello"),
5435            _ => panic!("expected BodyStr"),
5436        }
5437    }
5438
5439    /// Defaults: handler returns a non-record. The error path produces
5440    /// a 500 with a diagnostic. Unchanged from pre-wire-up behavior.
5441    #[test]
5442    fn unpack_response_falls_back_to_500_on_non_record() {
5443        let p = build_arena_response_program();
5444        let mut vm = Vm::new(&p);
5445        let v = Value::Int(7);
5446        let (status, _body, _headers) = unpack_response(&mut vm, &v);
5447        assert_eq!(status, 500);
5448    }
5449}