Skip to main content

lex_runtime/
handler.rs

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