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