Skip to main content

lex_runtime/
handler.rs

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