Skip to main content

cratera_executor/
lib.rs

1use cratera_common::{JobRequest, JobResponse, read_frame, read_line_bytes, write_frame};
2use serde::{Deserialize, Serialize};
3use serde_json::json;
4use std::fs;
5use std::io::{Read, Write};
6use std::os::unix::net::UnixStream;
7use std::os::unix::process::ExitStatusExt;
8use std::path::{Path, PathBuf};
9use std::process::{Child, Command, Stdio};
10use std::sync::Arc;
11use std::sync::atomic::{AtomicU32, Ordering};
12use std::time::{Duration, Instant};
13use tokio::sync::{Semaphore, oneshot};
14use tracing::{info, warn};
15
16const GUEST_VCPU: u8 = 2;
17const GUEST_MEM_MIB: u32 = 2048;
18const BOOT_WAIT: Duration = Duration::from_secs(20);
19const POWEROFF_GRACE: Duration = Duration::from_secs(3);
20const SNAP_WAIT: Duration = Duration::from_secs(60);
21const SNAP_CREATE_WAIT: Duration = Duration::from_secs(180);
22const JAIL_MEMORY_MAX: &str = "3221225472";
23const SNAP_MEMORY_MAX: &str = "6442450944";
24
25static NEXT_ID: AtomicU32 = AtomicU32::new(3);
26
27#[derive(Debug, Clone, thiserror::Error)]
28pub enum ExecError {
29    #[error("busy")]
30    Busy,
31    #[error("{0}")]
32    Failed(String),
33}
34
35#[derive(Debug, Clone)]
36pub struct JobOutcome {
37    pub job: JobResponse,
38    pub copy_ms: u64,
39    pub boot_ms: u64,
40    pub wall_ms: u64,
41    pub restored: bool,
42}
43
44#[derive(Clone, Debug, Serialize, Deserialize)]
45pub struct LanguageSpec {
46    #[serde(default)]
47    pub name: String,
48    #[serde(default)]
49    pub source: String,
50    #[serde(default)]
51    pub compile: Option<String>,
52    #[serde(default)]
53    pub run: String,
54    #[serde(default)]
55    pub is_rust: bool,
56    #[serde(default)]
57    pub enabled: Option<bool>,
58}
59
60#[derive(Clone, Debug, Default, Serialize, Deserialize)]
61struct LanguagesFile {
62    #[serde(default)]
63    pub languages: std::collections::HashMap<String, LanguageSpec>,
64    #[serde(flatten)]
65    pub direct: std::collections::HashMap<String, LanguageSpec>,
66}
67
68#[derive(Clone, Debug)]
69pub struct LanguageRegistry {
70    pub default_language: String,
71    pub specs: std::collections::HashMap<String, LanguageSpec>,
72}
73
74#[derive(Clone, Debug)]
75pub struct ResolvedLanguage {
76    pub key: String,
77    pub name: String,
78    pub source_file: String,
79    pub compile_cmd: Option<Vec<String>>,
80    pub run_cmd: Vec<String>,
81    pub is_rust: bool,
82}
83
84impl LanguageRegistry {
85    pub fn from_env_or_file() -> Self {
86        let mut specs = Self::builtin_specs();
87
88        let candidate_paths = [
89            std::env::var("CRATERA_LANGUAGES_FILE").ok(),
90            Some("languages.toml".to_string()),
91            Some("../languages.toml".to_string()),
92            Some("../../languages.toml".to_string()),
93            Some("/opt/cratera/languages.toml".to_string()),
94            Some("/etc/cratera/languages.toml".to_string()),
95        ];
96
97        for path_opt in candidate_paths.into_iter().flatten() {
98            if let Ok(content) = std::fs::read_to_string(&path_opt)
99                && let Ok(parsed) = toml::from_str::<LanguagesFile>(&content)
100            {
101                for (k, v) in parsed.languages {
102                    if v.enabled.unwrap_or(true) && !v.run.is_empty() {
103                        specs.insert(k.to_lowercase(), v);
104                    }
105                }
106                for (k, v) in parsed.direct {
107                    if v.enabled.unwrap_or(true) && !v.run.is_empty() {
108                        specs.insert(k.to_lowercase(), v);
109                    }
110                }
111                break;
112            }
113        }
114
115        let default_lang = std::env::var("CRATERA_LANGUAGE")
116            .unwrap_or_else(|_| "rust".to_string())
117            .to_lowercase();
118
119        if let Some(spec) = specs.get_mut(&default_lang) {
120            if let Ok(src) = std::env::var("CRATERA_SOURCE_FILE") {
121                spec.source = src;
122            }
123            if let Ok(compile) = std::env::var("CRATERA_COMPILE_CMD") {
124                spec.compile = if compile.trim().is_empty() || compile.trim() == "none" {
125                    None
126                } else {
127                    Some(compile)
128                };
129            }
130            if let Ok(run) = std::env::var("CRATERA_RUN_CMD") {
131                spec.run = run;
132            }
133        }
134
135        Self {
136            default_language: default_lang,
137            specs,
138        }
139    }
140
141    pub fn resolve(&self, lang: Option<&str>) -> Option<ResolvedLanguage> {
142        let raw = lang.unwrap_or(&self.default_language).trim().to_lowercase();
143        let key = match raw.as_str() {
144            "ts" | "typescript" => "typescript".to_string(),
145            "js" | "javascript" | "node" | "nodejs" => "node".to_string(),
146            "py" | "python" | "python3" => "python".to_string(),
147            "rs" | "rust" => "rust".to_string(),
148            "cs" | "c#" | "csharp" | "dotnet" => "csharp".to_string(),
149            "c++" | "cpp" | "cc" | "cxx" => "cpp".to_string(),
150            "c" | "clang" | "gcc" => "c".to_string(),
151            "go" | "golang" => "go".to_string(),
152            "java" => "java".to_string(),
153            other => other.to_string(),
154        };
155        let spec = self.specs.get(&key)?;
156
157        let source_file = if spec.source.starts_with('/') {
158            spec.source.clone()
159        } else {
160            format!("/tmp/{}", spec.source)
161        };
162
163        let compile_cmd = spec.compile.as_ref().and_then(|cmd| {
164            if cmd.trim().is_empty() || cmd.trim() == "none" {
165                None
166            } else {
167                let expanded = cmd.replace("{file}", &source_file);
168                Some(expanded.split_whitespace().map(String::from).collect())
169            }
170        });
171
172        let run_expanded = spec.run.replace("{file}", &source_file);
173        let run_cmd = run_expanded.split_whitespace().map(String::from).collect();
174
175        Some(ResolvedLanguage {
176            key,
177            name: if spec.name.is_empty() {
178                spec.source.clone()
179            } else {
180                spec.name.clone()
181            },
182            source_file,
183            compile_cmd,
184            run_cmd,
185            is_rust: spec.is_rust,
186        })
187    }
188
189    fn builtin_specs() -> std::collections::HashMap<String, LanguageSpec> {
190        let mut map = std::collections::HashMap::new();
191        map.insert(
192            "rust".to_string(),
193            LanguageSpec {
194                name: "Rust".into(),
195                source: "job.rs".into(),
196                compile: Some("rustc --edition 2024 -C panic=abort -C opt-level=2 -C link-arg=-fno-use-linker-plugin -o /tmp/job {file}".into()),
197                run: "/tmp/job".into(),
198                is_rust: true,
199                enabled: Some(true),
200            },
201        );
202        map.insert(
203            "python".to_string(),
204            LanguageSpec {
205                name: "Python".into(),
206                source: "job.py".into(),
207                compile: None,
208                run: "python3 {file}".into(),
209                is_rust: false,
210                enabled: Some(true),
211            },
212        );
213        map.insert(
214            "cpp".to_string(),
215            LanguageSpec {
216                name: "C++".into(),
217                source: "job.cpp".into(),
218                compile: Some("g++ -O3 -std=c++20 -o /tmp/job {file}".into()),
219                run: "/tmp/job".into(),
220                is_rust: false,
221                enabled: Some(true),
222            },
223        );
224        map.insert(
225            "c".to_string(),
226            LanguageSpec {
227                name: "C".into(),
228                source: "job.c".into(),
229                compile: Some("gcc -O3 -std=c17 -o /tmp/job {file}".into()),
230                run: "/tmp/job".into(),
231                is_rust: false,
232                enabled: Some(true),
233            },
234        );
235        map.insert(
236            "go".to_string(),
237            LanguageSpec {
238                name: "Go".into(),
239                source: "main.go".into(),
240                compile: Some("go build -o /tmp/job {file}".into()),
241                run: "/tmp/job".into(),
242                is_rust: false,
243                enabled: Some(true),
244            },
245        );
246        map.insert(
247            "node".to_string(),
248            LanguageSpec {
249                name: "JavaScript".into(),
250                source: "job.js".into(),
251                compile: None,
252                run: "node {file}".into(),
253                is_rust: false,
254                enabled: Some(true),
255            },
256        );
257        map.insert(
258            "typescript".to_string(),
259            LanguageSpec {
260                name: "TypeScript".into(),
261                source: "job.ts".into(),
262                compile: Some(
263                    "esbuild {file} --bundle --platform=node --outfile=/tmp/job.js".into(),
264                ),
265                run: "node /tmp/job.js".into(),
266                is_rust: false,
267                enabled: Some(true),
268            },
269        );
270        map.insert(
271            "java".to_string(),
272            LanguageSpec {
273                name: "Java".into(),
274                source: "Solution.java".into(),
275                compile: Some("javac -d /tmp {file}".into()),
276                run: "java -cp /tmp Solution".into(),
277                is_rust: false,
278                enabled: Some(true),
279            },
280        );
281        map.insert(
282            "csharp".to_string(),
283            LanguageSpec {
284                name: "C#".into(),
285                source: "Program.cs".into(),
286                compile: Some("mono /usr/lib/mono/4.5/mcs.exe -out:/tmp/job.exe {file}".into()),
287                run: "mono /tmp/job.exe".into(),
288                is_rust: false,
289                enabled: Some(true),
290            },
291        );
292        map.insert(
293            "zig".to_string(),
294            LanguageSpec {
295                name: "Zig".into(),
296                source: "job.zig".into(),
297                compile: Some("zig build-exe -O ReleaseFast -femit-bin=/tmp/job {file}".into()),
298                run: "/tmp/job".into(),
299                is_rust: false,
300                enabled: Some(true),
301            },
302        );
303        map
304    }
305}
306
307#[derive(Clone)]
308pub struct ExecutorConfig {
309    pub firecracker: PathBuf,
310    pub jailer: PathBuf,
311    pub kernel: PathBuf,
312    pub rootfs: PathBuf,
313    pub work_dir: PathBuf,
314    pub use_jailer: bool,
315    pub jail_uid: u32,
316    pub jail_gid: u32,
317    pub use_snapshot: bool,
318    pub snapshot_dir: PathBuf,
319    pub vcpu: u8,
320    pub mem_mib: u32,
321    pub compile_timeout: Duration,
322    pub jail_mem_max: String,
323    pub jail_pids_max: u32,
324    pub languages: LanguageRegistry,
325}
326
327impl ExecutorConfig {
328    pub fn from_env() -> Self {
329        fn env_path(cratera_key: &str, grade_key: &str, default: &str) -> PathBuf {
330            let val = std::env::var(cratera_key)
331                .or_else(|_| std::env::var(grade_key))
332                .unwrap_or_else(|_| default.to_string());
333            PathBuf::from(val)
334        }
335        fn env_flag_dual(cratera_key: &str, grade_key: &str, default: bool) -> bool {
336            std::env::var(cratera_key)
337                .or_else(|_| std::env::var(grade_key))
338                .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
339                .unwrap_or(default)
340        }
341        let rootfs = if let Ok(val) =
342            std::env::var("CRATERA_ROOTFS").or_else(|_| std::env::var("GRADE_ROOTFS"))
343        {
344            PathBuf::from(val)
345        } else if Path::new("./images/rootfs.squashfs").exists() {
346            PathBuf::from("./images/rootfs.squashfs")
347        } else {
348            PathBuf::from("./images/rootfs.ext4")
349        };
350        let snapshot_dir = std::env::var("CRATERA_SNAPSHOT_DIR")
351            .or_else(|_| std::env::var("GRADE_SNAPSHOT_DIR"))
352            .map(PathBuf::from)
353            .unwrap_or_else(|_| {
354                rootfs
355                    .parent()
356                    .unwrap_or_else(|| Path::new("."))
357                    .join("snapshot")
358            });
359        let vcpu = std::env::var("CRATERA_VCPU")
360            .or_else(|_| std::env::var("GRADE_VCPU"))
361            .ok()
362            .and_then(|s| s.parse().ok())
363            .unwrap_or(GUEST_VCPU);
364        let mem_mib = std::env::var("CRATERA_MEM_MIB")
365            .or_else(|_| std::env::var("GRADE_MEM_MIB"))
366            .ok()
367            .and_then(|s| s.parse().ok())
368            .unwrap_or(GUEST_MEM_MIB);
369        let compile_timeout_secs = std::env::var("CRATERA_COMPILE_TIMEOUT_SECS")
370            .ok()
371            .and_then(|s| s.parse().ok())
372            .unwrap_or(12);
373        let jail_mem_max =
374            std::env::var("CRATERA_JAIL_MEM_MAX").unwrap_or_else(|_| JAIL_MEMORY_MAX.into());
375        let jail_pids_max = std::env::var("CRATERA_JAIL_PIDS_MAX")
376            .ok()
377            .and_then(|s| s.parse().ok())
378            .unwrap_or(64);
379
380        Self {
381            firecracker: env_path(
382                "CRATERA_FIRECRACKER",
383                "GRADE_FIRECRACKER",
384                "./images/firecracker",
385            ),
386            jailer: env_path("CRATERA_JAILER", "GRADE_JAILER", "./images/jailer"),
387            kernel: env_path("CRATERA_KERNEL", "GRADE_KERNEL", "./images/vmlinux.bin"),
388            rootfs,
389            work_dir: env_path("CRATERA_WORK_DIR", "GRADE_WORK_DIR", "/var/tmp/cratera"),
390            use_jailer: env_flag_dual("CRATERA_USE_JAILER", "GRADE_USE_JAILER", false),
391            jail_uid: std::env::var("CRATERA_JAIL_UID")
392                .or_else(|_| std::env::var("GRADE_JAIL_UID"))
393                .ok()
394                .and_then(|s| s.parse().ok())
395                .unwrap_or(20001),
396            jail_gid: std::env::var("CRATERA_JAIL_GID")
397                .or_else(|_| std::env::var("GRADE_JAIL_GID"))
398                .ok()
399                .and_then(|s| s.parse().ok())
400                .unwrap_or(20001),
401            use_snapshot: env_flag_dual("CRATERA_USE_SNAPSHOT", "GRADE_USE_SNAPSHOT", false),
402            snapshot_dir,
403            vcpu,
404            mem_mib,
405            compile_timeout: Duration::from_secs(compile_timeout_secs),
406            jail_mem_max,
407            jail_pids_max,
408            languages: LanguageRegistry::from_env_or_file(),
409        }
410    }
411}
412
413pub struct FirecrackerExecutor {
414    cfg: ExecutorConfig,
415    slots: Arc<Semaphore>,
416}
417
418impl FirecrackerExecutor {
419    pub fn new(cfg: ExecutorConfig) -> Self {
420        Self {
421            cfg,
422            slots: Arc::new(Semaphore::new(1)),
423        }
424    }
425
426    pub fn config(&self) -> &ExecutorConfig {
427        &self.cfg
428    }
429
430    pub fn ensure_snapshot(&self) -> Result<(), ExecError> {
431        if !self.cfg.use_snapshot {
432            return Ok(());
433        }
434        if !self.cfg.use_jailer {
435            warn!("CRATERA_USE_SNAPSHOT ignored without jailer (vsock paths are not portable)");
436            return Ok(());
437        }
438        let snap = snap_paths(&self.cfg);
439        if snap.ready() {
440            info!(
441                snap = %snap.state.display(),
442                mem = %snap.mem.display(),
443                "using existing Firecracker snapshot"
444            );
445            return Ok(());
446        }
447        info!("creating Firecracker snapshot (agent listen)");
448        create_golden_snapshot(&self.cfg)?;
449        chmod_snapshot_group(&self.cfg)?;
450        if !snap.ready() {
451            return Err(ExecError::Failed(
452                "snapshot create did not write files".into(),
453            ));
454        }
455        info!(
456            snap = %snap.state.display(),
457            mem = %snap.mem.display(),
458            "Firecracker snapshot ready"
459        );
460        Ok(())
461    }
462
463    pub async fn run_harness(
464        &self,
465        source: String,
466        timeout_ms: u64,
467        lang: Option<ResolvedLanguage>,
468    ) -> Result<JobOutcome, ExecError> {
469        let permit = match tokio::time::timeout(
470            std::time::Duration::from_secs(10),
471            self.slots.clone().acquire_owned(),
472        )
473        .await
474        {
475            Ok(Ok(p)) => p,
476            Ok(Err(_)) => return Err(ExecError::Failed("executor closed".into())),
477            Err(_) => return Err(ExecError::Busy),
478        };
479        let cfg = self.cfg.clone();
480        let target_lang = lang.unwrap_or_else(|| {
481            self.cfg
482                .languages
483                .resolve(None)
484                .expect("default language must resolve")
485        });
486        let (tx, rx) = oneshot::channel();
487        tokio::task::spawn_blocking(move || {
488            let result = run_sync(&cfg, &source, timeout_ms, target_lang, tx);
489            drop(permit);
490            if let Err(e) = &result {
491                warn!(error = %e, "job failed after verdict channel closed");
492            }
493        });
494        rx.await
495            .map_err(|_| ExecError::Failed("job task dropped before verdict".into()))?
496    }
497}
498
499struct JobLayout {
500    id: String,
501    jail_root: PathBuf,
502    host_vsock: PathBuf,
503    host_api: PathBuf,
504    vm_json: PathBuf,
505    cid: u32,
506}
507
508struct SnapPaths {
509    state: PathBuf,
510    mem: PathBuf,
511}
512
513impl SnapPaths {
514    fn ready(&self) -> bool {
515        self.state.is_file() && self.mem.is_file()
516    }
517}
518
519fn snap_paths(cfg: &ExecutorConfig) -> SnapPaths {
520    SnapPaths {
521        state: cfg.snapshot_dir.join("vm.snap"),
522        mem: cfg.snapshot_dir.join("vm.mem"),
523    }
524}
525
526fn run_sync(
527    cfg: &ExecutorConfig,
528    source: &str,
529    timeout_ms: u64,
530    lang: ResolvedLanguage,
531    tx: oneshot::Sender<Result<JobOutcome, ExecError>>,
532) -> Result<JobOutcome, ExecError> {
533    let wall_start = Instant::now();
534    let t_copy = Instant::now();
535    let layout = match prepare_job(cfg) {
536        Ok(layout) => layout,
537        Err(e) => {
538            let _ = tx.send(Err(e.clone()));
539            return Err(e);
540        }
541    };
542    let copy_ms = t_copy.elapsed().as_millis() as u64;
543
544    let snap = snap_paths(cfg);
545    let restore = cfg.use_snapshot && cfg.use_jailer && snap.ready();
546    let wall = cfg.compile_timeout + Duration::from_millis(timeout_ms) + BOOT_WAIT + POWEROFF_GRACE;
547
548    let result = run_vm(
549        cfg, &layout, source, timeout_ms, wall, copy_ms, restore, wall_start, &lang, tx,
550    );
551    cleanup(&layout.jail_root);
552    result
553}
554
555fn prepare_job(cfg: &ExecutorConfig) -> Result<JobLayout, ExecError> {
556    fs::create_dir_all(&cfg.work_dir).map_err(io_err)?;
557    let cid = NEXT_ID.fetch_add(1, Ordering::Relaxed).max(3);
558    let id = format!("job-{cid}");
559
560    let jail_root = if cfg.use_jailer {
561        cfg.work_dir.join("firecracker").join(&id).join("root")
562    } else {
563        cfg.work_dir.join(&id)
564    };
565
566    let build = || -> Result<JobLayout, ExecError> {
567        for dir in ["kernel", "disk", "config", "vsock", "snapshot"] {
568            fs::create_dir_all(jail_root.join(dir)).map_err(io_err)?;
569        }
570
571        let kernel_dst = jail_root.join("kernel/vmlinux.bin");
572        let rootfs_dst = jail_root.join("disk/rootfs.ext4");
573        hardlink_or_copy(&cfg.kernel, &kernel_dst)?;
574        hardlink_or_copy(&cfg.rootfs, &rootfs_dst)?;
575
576        let snap = snap_paths(cfg);
577        if cfg.use_snapshot && cfg.use_jailer && snap.ready() {
578            hardlink_or_copy(&snap.state, &jail_root.join("snapshot/vm.snap"))?;
579            hardlink_or_copy(&snap.mem, &jail_root.join("snapshot/vm.mem"))?;
580        }
581
582        let (kernel_path, rootfs_path, uds_path) = if cfg.use_jailer {
583            (
584                "/kernel/vmlinux.bin".to_string(),
585                "/disk/rootfs.ext4".to_string(),
586                "/vsock/job.sock".to_string(),
587            )
588        } else {
589            (
590                kernel_dst.to_string_lossy().into_owned(),
591                rootfs_dst.to_string_lossy().into_owned(),
592                jail_root
593                    .join("vsock/job.sock")
594                    .to_string_lossy()
595                    .into_owned(),
596            )
597        };
598
599        let vm = vm_config_json(
600            &kernel_path,
601            &rootfs_path,
602            &uds_path,
603            cid,
604            cfg.vcpu,
605            cfg.mem_mib,
606        );
607        let vm_json = jail_root.join("config/vm.json");
608        fs::write(
609            &vm_json,
610            serde_json::to_vec_pretty(&vm).map_err(|e| ExecError::Failed(e.to_string()))?,
611        )
612        .map_err(io_err)?;
613
614        if cfg.use_jailer {
615            chown_runtime(cfg, &jail_root)?;
616        }
617
618        Ok(JobLayout {
619            id,
620            host_vsock: jail_root.join("vsock/job.sock"),
621            host_api: jail_root.join("api.sock"),
622            jail_root: jail_root.clone(),
623            vm_json,
624            cid,
625        })
626    };
627
628    match build() {
629        Ok(layout) => Ok(layout),
630        Err(e) => {
631            cleanup(&jail_root);
632            Err(e)
633        }
634    }
635}
636
637fn vm_config_json(
638    kernel_path: &str,
639    rootfs_path: &str,
640    uds_path: &str,
641    cid: u32,
642    vcpu: u8,
643    mem_mib: u32,
644) -> serde_json::Value {
645    json!({
646        "boot-source": {
647            "kernel_image_path": kernel_path,
648            "boot_args": "reboot=k panic=1 pci=off nomodule root=/dev/vda ro init=/sbin/cratera-agent quiet"
649        },
650        "machine-config": {
651            "vcpu_count": vcpu,
652            "mem_size_mib": mem_mib,
653            "smt": false
654        },
655        "drives": [{
656            "drive_id": "rootfs",
657            "path_on_host": rootfs_path,
658            "is_root_device": true,
659            "is_read_only": true
660        }],
661        "vsock": {
662            "guest_cid": cid,
663            "uds_path": uds_path
664        }
665    })
666}
667
668fn chown_runtime(cfg: &ExecutorConfig, jail_root: &Path) -> Result<(), ExecError> {
669    let uid = cfg.jail_uid;
670    let gid = cfg.jail_gid;
671    for rel in ["", "config", "vsock", "snapshot", "kernel", "disk"] {
672        let path = if rel.is_empty() {
673            jail_root.to_path_buf()
674        } else {
675            jail_root.join(rel)
676        };
677        let _ = std::os::unix::fs::chown(&path, Some(uid), Some(gid));
678    }
679    let vm_json = jail_root.join("config/vm.json");
680    let _ = std::os::unix::fs::chown(&vm_json, Some(uid), Some(gid));
681    Ok(())
682}
683
684#[allow(clippy::too_many_arguments)]
685fn run_vm(
686    cfg: &ExecutorConfig,
687    layout: &JobLayout,
688    source: &str,
689    timeout_ms: u64,
690    wall: Duration,
691    copy_ms: u64,
692    restore: bool,
693    wall_start: Instant,
694    lang: &ResolvedLanguage,
695    tx: oneshot::Sender<Result<JobOutcome, ExecError>>,
696) -> Result<JobOutcome, ExecError> {
697    let mut child = match if restore {
698        spawn_vm(cfg, layout, SpawnMode::ApiOnly, false)
699    } else {
700        spawn_vm(cfg, layout, SpawnMode::ConfigNoApi, false)
701    } {
702        Ok(child) => child,
703        Err(e) => {
704            let _ = tx.send(Err(e.clone()));
705            return Err(e);
706        }
707    };
708    let boot_t0 = Instant::now();
709    info!(job = %layout.id, cid = layout.cid, restore, language = %lang.key, "microVM started");
710
711    let rpc = (|| {
712        if restore && let Err(e) = load_snapshot(cfg, layout) {
713            warn!(error = %e, "snapshot restore failed; killing VM");
714            return Err(e);
715        }
716        let mut stream = wait_connect(
717            &layout.host_vsock,
718            BOOT_WAIT.min(wall.saturating_sub(boot_t0.elapsed())),
719        )?;
720        let boot_ms = boot_t0.elapsed().as_millis() as u64;
721        let job = send_job(
722            &mut stream,
723            source,
724            timeout_ms,
725            lang,
726            wall.saturating_sub(boot_t0.elapsed()),
727        )?;
728        Ok((job, boot_ms))
729    })();
730    let (rpc, boot_ms) = match rpc {
731        Ok((job, boot_ms)) => (Ok(job), boot_ms),
732        Err(e) => (Err(e), boot_t0.elapsed().as_millis() as u64),
733    };
734    let died = child.try_wait().ok().flatten();
735    let oom = died.and_then(|s| s.signal()) == Some(9);
736    let wall_ms = wall_start.elapsed().as_millis() as u64;
737    let mapped = match rpc {
738        Ok(job) => Ok(JobOutcome {
739            job,
740            copy_ms,
741            boot_ms,
742            wall_ms,
743            restored: restore,
744        }),
745        Err(_) if wall_start.elapsed() >= wall => Ok(JobOutcome {
746            job: JobResponse {
747                timed_out: true,
748                compilation_success: true,
749                run_ms: wall_start.elapsed().as_micros() as u64,
750                ..Default::default()
751            },
752            copy_ms,
753            boot_ms,
754            wall_ms,
755            restored: restore,
756        }),
757        Err(_) if oom => Ok(JobOutcome {
758            job: JobResponse {
759                oom: true,
760                compilation_success: true,
761                run_ms: wall_start.elapsed().as_micros() as u64,
762                ..Default::default()
763            },
764            copy_ms,
765            boot_ms,
766            wall_ms,
767            restored: restore,
768        }),
769        Err(e) => Err(e),
770    };
771    if let Ok(o) = &mapped {
772        info!(
773            job = %layout.id,
774            copy_ms = o.copy_ms,
775            boot_ms = o.boot_ms,
776            compile_ms = o.job.compile_ms,
777            run_us = o.job.run_ms,
778            wall_ms = o.wall_ms,
779            restored = o.restored,
780            "harness phases"
781        );
782    }
783    let _ = tx.send(mapped.clone());
784
785    let reap_t0 = Instant::now();
786    reap_vm(layout, &mut child);
787    info!(
788        job = %layout.id,
789        reap_ms = reap_t0.elapsed().as_millis() as u64,
790        "microVM reaped"
791    );
792    mapped
793}
794
795fn load_snapshot(cfg: &ExecutorConfig, layout: &JobLayout) -> Result<(), ExecError> {
796    wait_path(&layout.host_api, Duration::from_secs(5))?;
797    let (snap_path, mem_path) = if cfg.use_jailer {
798        (
799            "/snapshot/vm.snap".to_string(),
800            "/snapshot/vm.mem".to_string(),
801        )
802    } else {
803        (
804            layout
805                .jail_root
806                .join("snapshot/vm.snap")
807                .to_string_lossy()
808                .into_owned(),
809            layout
810                .jail_root
811                .join("snapshot/vm.mem")
812                .to_string_lossy()
813                .into_owned(),
814        )
815    };
816    fc_put(
817        &layout.host_api,
818        "/snapshot/load",
819        &json!({
820            "snapshot_path": snap_path,
821            "mem_backend": {
822                "backend_path": mem_path,
823                "backend_type": "File"
824            },
825            "resume_vm": true
826        }),
827    )?;
828    Ok(())
829}
830
831fn create_golden_snapshot(cfg: &ExecutorConfig) -> Result<(), ExecError> {
832    fs::create_dir_all(&cfg.snapshot_dir).map_err(io_err)?;
833    let t0 = Instant::now();
834    let layout = prepare_job(cfg)?;
835    let fc_log = layout.jail_root.join("config/fc.log");
836    let _ = fs::write(&fc_log, b"");
837    if cfg.use_jailer {
838        let spec = format!("{}:{}", cfg.jail_uid, cfg.jail_gid);
839        let _ = Command::new("chown")
840            .args([&spec, fc_log.to_str().unwrap_or(".")])
841            .status();
842    }
843    let mut child = spawn_vm(cfg, &layout, SpawnMode::ConfigWithApi, true)?;
844    let result = (|| {
845        wait_path(&layout.host_api, BOOT_WAIT)?;
846        info!("snapshot vm api ready");
847        wait_connect_probe(&layout.host_vsock, BOOT_WAIT)?;
848        info!("snapshot vm agent listening");
849        std::thread::sleep(Duration::from_millis(100));
850        fc_patch(&layout.host_api, "/vm", &json!({ "state": "Paused" }))?;
851        info!("snapshot vm paused");
852        let (snap_path, mem_path) = if cfg.use_jailer {
853            (
854                "/snapshot/vm.snap".to_string(),
855                "/snapshot/vm.mem".to_string(),
856            )
857        } else {
858            (
859                layout
860                    .jail_root
861                    .join("snapshot/vm.snap")
862                    .to_string_lossy()
863                    .into_owned(),
864                layout
865                    .jail_root
866                    .join("snapshot/vm.mem")
867                    .to_string_lossy()
868                    .into_owned(),
869            )
870        };
871        info!("writing Firecracker snapshot files");
872        fc_http_timeout(
873            &layout.host_api,
874            "PUT",
875            "/snapshot/create",
876            &json!({
877                "snapshot_type": "Full",
878                "snapshot_path": snap_path,
879                "mem_file_path": mem_path
880            }),
881            SNAP_CREATE_WAIT,
882        )?;
883        info!("snapshot files written in jail");
884        let src_state = layout.jail_root.join("snapshot/vm.snap");
885        let src_mem = layout.jail_root.join("snapshot/vm.mem");
886        fs::copy(&src_state, snap_paths(cfg).state).map_err(io_err)?;
887        fs::copy(&src_mem, snap_paths(cfg).mem).map_err(io_err)?;
888        chmod_snapshot_group(cfg)?;
889        Ok(())
890    })();
891    reap_vm(&layout, &mut child);
892    let _ = Command::new("pkill")
893        .args(["-9", "-x", "firecracker"])
894        .status();
895    let _ = Command::new("pkill").args(["-9", "-x", "jailer"]).status();
896    if let Err(e) = &result
897        && let Ok(log) = fs::read_to_string(&fc_log)
898    {
899        let tail: String = log
900            .chars()
901            .rev()
902            .take(800)
903            .collect::<String>()
904            .chars()
905            .rev()
906            .collect();
907        if !tail.is_empty() {
908            warn!(error = %e, fc_log = %tail, "snapshot create failed");
909        }
910    }
911    cleanup(&layout.jail_root);
912    match result {
913        Ok(()) => {
914            info!(
915                create_ms = t0.elapsed().as_millis() as u64,
916                "golden snapshot written"
917            );
918            Ok(())
919        }
920        Err(e) => {
921            let _ = fs::remove_file(snap_paths(cfg).state);
922            let _ = fs::remove_file(snap_paths(cfg).mem);
923            Err(e)
924        }
925    }
926}
927
928#[derive(Clone, Copy)]
929enum SpawnMode {
930    ConfigNoApi,
931    ConfigWithApi,
932    ApiOnly,
933}
934
935fn spawn_vm(
936    cfg: &ExecutorConfig,
937    layout: &JobLayout,
938    mode: SpawnMode,
939    snapshot_create: bool,
940) -> Result<Child, ExecError> {
941    let api_guest = "/api.sock";
942    let api_host = layout.host_api.to_string_lossy().into_owned();
943    let cfg_guest = "/config/vm.json";
944    let cfg_host = layout.vm_json.to_string_lossy().into_owned();
945    let mem_max = if snapshot_create {
946        SNAP_MEMORY_MAX
947    } else {
948        &cfg.jail_mem_max
949    };
950
951    let mut cmd = if cfg.use_jailer {
952        let mut c = Command::new(&cfg.jailer);
953        c.args([
954            "--id",
955            &layout.id,
956            "--exec-file",
957            cfg.firecracker
958                .to_str()
959                .ok_or_else(|| ExecError::Failed("firecracker path".into()))?,
960            "--uid",
961            &cfg.jail_uid.to_string(),
962            "--gid",
963            &cfg.jail_gid.to_string(),
964            "--chroot-base-dir",
965            cfg.work_dir
966                .to_str()
967                .ok_or_else(|| ExecError::Failed("work dir".into()))?,
968            "--cgroup-version",
969            "2",
970            "--cgroup",
971            &format!("memory.max={mem_max}"),
972            "--cgroup",
973            &format!("pids.max={}", cfg.jail_pids_max),
974            "--new-pid-ns",
975            "--",
976        ]);
977        match mode {
978            SpawnMode::ConfigNoApi => {
979                c.args(["--no-api", "--config-file", cfg_guest]);
980            }
981            SpawnMode::ConfigWithApi => {
982                c.args([
983                    "--api-sock",
984                    api_guest,
985                    "--config-file",
986                    cfg_guest,
987                    "--log-path",
988                    "/config/fc.log",
989                    "--level",
990                    "Info",
991                ]);
992            }
993            SpawnMode::ApiOnly => {
994                c.args(["--api-sock", api_guest]);
995            }
996        }
997        c
998    } else {
999        let mut c = Command::new(&cfg.firecracker);
1000        match mode {
1001            SpawnMode::ConfigNoApi => {
1002                c.args(["--no-api", "--config-file", &cfg_host]);
1003            }
1004            SpawnMode::ConfigWithApi => {
1005                c.args(["--api-sock", &api_host, "--config-file", &cfg_host]);
1006            }
1007            SpawnMode::ApiOnly => {
1008                c.args(["--api-sock", &api_host]);
1009            }
1010        }
1011        c
1012    };
1013    cmd.stdin(Stdio::null())
1014        .stdout(Stdio::null())
1015        .stderr(Stdio::null())
1016        .spawn()
1017        .map_err(io_err)
1018}
1019
1020const CGROUP_BASES: &[&str] = &[
1021    "/sys/fs/cgroup/firecracker",
1022    "/sys/fs/cgroup/jailer/firecracker",
1023    "/sys/fs/cgroup/jailer",
1024    "/sys/fs/cgroup",
1025];
1026
1027fn reap_vm(layout: &JobLayout, child: &mut Child) {
1028    let _ = child.kill();
1029
1030    for base in CGROUP_BASES {
1031        let kill_path = format!("{base}/{}/cgroup.kill", layout.id);
1032        let p = Path::new(&kill_path);
1033        if p.is_file() {
1034            let _ = fs::write(p, b"1\n");
1035        }
1036    }
1037
1038    let pattern = format!("--id {}\\b", layout.id);
1039    let _ = Command::new("pkill")
1040        .args(["-9", "-f", "--", &pattern])
1041        .status();
1042
1043    let deadline = Instant::now() + Duration::from_secs(2);
1044    while Instant::now() < deadline {
1045        match child.try_wait() {
1046            Ok(Some(_)) => break,
1047            Ok(None) => std::thread::sleep(Duration::from_millis(20)),
1048            Err(_) => break,
1049        }
1050    }
1051
1052    for base in CGROUP_BASES {
1053        let dir_path = format!("{base}/{}", layout.id);
1054        let p = Path::new(&dir_path);
1055        if p.is_dir() {
1056            let deadline = Instant::now() + Duration::from_millis(500);
1057            while Instant::now() < deadline {
1058                if fs::remove_dir(p).is_ok() || !p.exists() {
1059                    break;
1060                }
1061                std::thread::sleep(Duration::from_millis(25));
1062            }
1063        }
1064    }
1065}
1066
1067fn send_job(
1068    stream: &mut UnixStream,
1069    source: &str,
1070    timeout_ms: u64,
1071    lang: &ResolvedLanguage,
1072    remaining: Duration,
1073) -> Result<JobResponse, ExecError> {
1074    let bytes = serde_json::to_vec(&JobRequest {
1075        source: source.to_string(),
1076        timeout_ms,
1077        source_file: Some(lang.source_file.clone()),
1078        compile_cmd: lang.compile_cmd.clone(),
1079        run_cmd: Some(lang.run_cmd.clone()),
1080    })
1081    .map_err(|e| ExecError::Failed(e.to_string()))?;
1082    write_frame(stream, &bytes).map_err(io_err)?;
1083    stream
1084        .set_read_timeout(Some(remaining.max(Duration::from_secs(1))))
1085        .map_err(io_err)?;
1086    let resp_bytes = read_frame(stream).map_err(io_err)?;
1087    serde_json::from_slice(&resp_bytes).map_err(|e| ExecError::Failed(e.to_string()))
1088}
1089
1090fn wait_connect(uds: &Path, timeout: Duration) -> Result<UnixStream, ExecError> {
1091    let start = Instant::now();
1092    let mut last = String::new();
1093    while start.elapsed() < timeout {
1094        match UnixStream::connect(uds) {
1095            Ok(mut stream) => {
1096                let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
1097                let _ = stream.set_write_timeout(Some(Duration::from_millis(200)));
1098                if stream.write_all(b"CONNECT 52\n").is_err() {
1099                    std::thread::sleep(Duration::from_millis(50));
1100                    continue;
1101                }
1102                match read_line_bytes(&mut stream) {
1103                    Ok(line) if line.starts_with("OK") => {
1104                        let _ = stream.set_read_timeout(None);
1105                        let _ = stream.set_write_timeout(None);
1106                        return Ok(stream);
1107                    }
1108                    Ok(line) => last = format!("vsock handshake: {line}"),
1109                    Err(e) => last = e.to_string(),
1110                }
1111            }
1112            Err(e) => last = e.to_string(),
1113        }
1114        std::thread::sleep(Duration::from_millis(50));
1115    }
1116    Err(ExecError::Failed(format!("agent vsock not ready: {last}")))
1117}
1118
1119fn wait_connect_probe(uds: &Path, timeout: Duration) -> Result<(), ExecError> {
1120    let _stream = wait_connect(uds, timeout)?;
1121    Ok(())
1122}
1123
1124fn wait_path(path: &Path, timeout: Duration) -> Result<(), ExecError> {
1125    let start = Instant::now();
1126    while start.elapsed() < timeout {
1127        if path.exists() {
1128            return Ok(());
1129        }
1130        std::thread::sleep(Duration::from_millis(20));
1131    }
1132    Err(ExecError::Failed(format!(
1133        "timed out waiting for {}",
1134        path.display()
1135    )))
1136}
1137
1138fn fc_patch(sock: &Path, url_path: &str, body: &serde_json::Value) -> Result<(), ExecError> {
1139    fc_http_timeout(sock, "PATCH", url_path, body, SNAP_WAIT)
1140}
1141
1142fn fc_put(sock: &Path, url_path: &str, body: &serde_json::Value) -> Result<(), ExecError> {
1143    fc_http_timeout(sock, "PUT", url_path, body, SNAP_WAIT)
1144}
1145
1146fn fc_http_timeout(
1147    sock: &Path,
1148    method: &str,
1149    url_path: &str,
1150    body: &serde_json::Value,
1151    read_timeout: Duration,
1152) -> Result<(), ExecError> {
1153    let payload = serde_json::to_vec(body).map_err(|e| ExecError::Failed(e.to_string()))?;
1154    let mut stream = UnixStream::connect(sock).map_err(io_err)?;
1155    stream
1156        .set_read_timeout(Some(read_timeout))
1157        .map_err(io_err)?;
1158    stream
1159        .set_write_timeout(Some(Duration::from_secs(10)))
1160        .map_err(io_err)?;
1161    let req = format!(
1162        "{method} {url_path} HTTP/1.1\r\nHost: localhost\r\nAccept: application/json\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
1163        payload.len()
1164    );
1165    stream.write_all(req.as_bytes()).map_err(io_err)?;
1166    stream.write_all(&payload).map_err(io_err)?;
1167    stream.flush().map_err(io_err)?;
1168    let text = read_http_response(&mut stream)?;
1169    let status = text
1170        .lines()
1171        .next()
1172        .and_then(|l| l.split_whitespace().nth(1))
1173        .unwrap_or("0");
1174    if status.starts_with('2') {
1175        Ok(())
1176    } else {
1177        Err(ExecError::Failed(format!(
1178            "firecracker {method} {url_path} -> {status}: {}",
1179            text.chars().take(400).collect::<String>()
1180        )))
1181    }
1182}
1183
1184fn read_http_response(stream: &mut impl Read) -> Result<String, ExecError> {
1185    let mut buf = Vec::new();
1186    let mut tmp = [0u8; 1024];
1187    let header_end = loop {
1188        let n = stream.read(&mut tmp).map_err(io_err)?;
1189        if n == 0 {
1190            break None;
1191        }
1192        buf.extend_from_slice(&tmp[..n]);
1193        if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
1194            break Some(pos + 4);
1195        }
1196        if buf.len() > 64 * 1024 {
1197            return Err(ExecError::Failed(
1198                "firecracker HTTP headers too large".into(),
1199            ));
1200        }
1201    };
1202    let Some(header_end) = header_end else {
1203        return Err(ExecError::Failed("firecracker closed API socket".into()));
1204    };
1205    let headers = String::from_utf8_lossy(&buf[..header_end]);
1206    let content_len = headers.lines().find_map(|line| {
1207        let (name, value) = line.split_once(':')?;
1208        if name.eq_ignore_ascii_case("content-length") {
1209            value.trim().parse::<usize>().ok()
1210        } else {
1211            None
1212        }
1213    });
1214    if let Some(len) = content_len {
1215        while buf.len() < header_end + len {
1216            let n = stream.read(&mut tmp).map_err(io_err)?;
1217            if n == 0 {
1218                break;
1219            }
1220            buf.extend_from_slice(&tmp[..n]);
1221        }
1222    }
1223    Ok(String::from_utf8_lossy(&buf).into_owned())
1224}
1225
1226fn chmod_snapshot_group(cfg: &ExecutorConfig) -> Result<(), ExecError> {
1227    use std::os::unix::fs::PermissionsExt;
1228    let snap = snap_paths(cfg);
1229    for path in [&snap.state, &snap.mem] {
1230        let _ = std::os::unix::fs::chown(path, Some(0), Some(cfg.jail_gid));
1231        let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o664));
1232    }
1233    Ok(())
1234}
1235
1236fn hardlink_or_copy(src: &Path, dst: &Path) -> Result<(), ExecError> {
1237    let _ = fs::remove_file(dst);
1238    if fs::hard_link(src, dst).is_ok() {
1239        return Ok(());
1240    }
1241    fs::copy(src, dst).map_err(io_err)?;
1242    Ok(())
1243}
1244
1245fn cleanup(jail_root: &Path) {
1246    let to_remove = if jail_root.file_name().and_then(|n| n.to_str()) == Some("root") {
1247        jail_root.parent().unwrap_or(jail_root)
1248    } else {
1249        jail_root
1250    };
1251    if let Err(e) = fs::remove_dir_all(to_remove) {
1252        warn!(path = %to_remove.display(), error = %e, "job cleanup failed");
1253    }
1254}
1255
1256fn io_err(e: std::io::Error) -> ExecError {
1257    ExecError::Failed(e.to_string())
1258}
1259
1260#[cfg(test)]
1261mod tests {
1262    use super::*;
1263    use std::io::Cursor;
1264    use std::os::unix::net::UnixListener;
1265    use std::thread;
1266
1267    #[test]
1268    fn http_204_does_not_wait_for_peer_close() {
1269        let path = std::env::temp_dir().join(format!("grade-fc-http-{}.sock", std::process::id()));
1270        let _ = fs::remove_file(&path);
1271        let listener = UnixListener::bind(&path).unwrap();
1272        let server = thread::spawn(move || {
1273            let (mut s, _) = listener.accept().unwrap();
1274            let mut ignore = [0u8; 512];
1275            let _ = s.read(&mut ignore);
1276            s.write_all(b"HTTP/1.1 204 No Content\r\n\r\n").unwrap();
1277            s.flush().unwrap();
1278            let _ = s.read(&mut [0u8; 1]);
1279        });
1280        let mut client = UnixStream::connect(&path).unwrap();
1281        client
1282            .set_read_timeout(Some(Duration::from_secs(2)))
1283            .unwrap();
1284        client.write_all(b"PATCH /vm HTTP/1.1\r\n\r\n").unwrap();
1285        client.flush().unwrap();
1286        let started = Instant::now();
1287        let text = read_http_response(&mut client).unwrap();
1288        let _ = fs::remove_file(&path);
1289        assert!(
1290            started.elapsed() < Duration::from_secs(2),
1291            "must not wait for the peer to close"
1292        );
1293        assert!(text.contains("204"));
1294        drop(server);
1295    }
1296
1297    #[test]
1298    fn http_reads_content_length_body() {
1299        let raw = b"HTTP/1.1 400 Bad Request\r\nContent-Length: 5\r\n\r\nbad!!";
1300        let text = read_http_response(&mut Cursor::new(&raw[..])).unwrap();
1301        assert!(text.ends_with("bad!!"));
1302    }
1303
1304    #[test]
1305    fn test_language_registry_defaults_to_rust() {
1306        let registry = LanguageRegistry::from_env_or_file();
1307        let resolved = registry.resolve(None).expect("rust should resolve");
1308        assert_eq!(resolved.key, "rust");
1309        assert_eq!(resolved.source_file, "/tmp/job.rs");
1310        assert!(resolved.is_rust);
1311        assert!(resolved.compile_cmd.is_some());
1312    }
1313
1314    #[test]
1315    fn test_language_registry_resolves_python() {
1316        let registry = LanguageRegistry::from_env_or_file();
1317        let resolved = registry
1318            .resolve(Some("python"))
1319            .expect("python should resolve");
1320        assert_eq!(resolved.key, "python");
1321        assert_eq!(resolved.source_file, "/tmp/job.py");
1322        assert!(!resolved.is_rust);
1323        assert!(resolved.compile_cmd.is_none());
1324        assert_eq!(resolved.run_cmd, vec!["python3", "/tmp/job.py"]);
1325    }
1326
1327    #[test]
1328    fn test_all_nine_languages_resolve_and_expand_commands() {
1329        let registry = LanguageRegistry::from_env_or_file();
1330
1331        let cases = [
1332            // (input_alias, expected_key, expected_src, has_compile, expected_run_bin)
1333            ("rust", "rust", "/tmp/job.rs", true, "/tmp/job"),
1334            ("rs", "rust", "/tmp/job.rs", true, "/tmp/job"),
1335            ("python", "python", "/tmp/job.py", false, "python3"),
1336            ("py", "python", "/tmp/job.py", false, "python3"),
1337            ("python3", "python", "/tmp/job.py", false, "python3"),
1338            ("typescript", "typescript", "/tmp/job.ts", true, "node"),
1339            ("ts", "typescript", "/tmp/job.ts", true, "node"),
1340            ("node", "node", "/tmp/job.js", false, "node"),
1341            ("js", "node", "/tmp/job.js", false, "node"),
1342            ("javascript", "node", "/tmp/job.js", false, "node"),
1343            ("nodejs", "node", "/tmp/job.js", false, "node"),
1344            ("go", "go", "/tmp/main.go", true, "/tmp/job"),
1345            ("golang", "go", "/tmp/main.go", true, "/tmp/job"),
1346            ("cpp", "cpp", "/tmp/job.cpp", true, "/tmp/job"),
1347            ("c++", "cpp", "/tmp/job.cpp", true, "/tmp/job"),
1348            ("cc", "cpp", "/tmp/job.cpp", true, "/tmp/job"),
1349            ("cxx", "cpp", "/tmp/job.cpp", true, "/tmp/job"),
1350            ("c", "c", "/tmp/job.c", true, "/tmp/job"),
1351            ("clang", "c", "/tmp/job.c", true, "/tmp/job"),
1352            ("gcc", "c", "/tmp/job.c", true, "/tmp/job"),
1353            ("java", "java", "/tmp/Solution.java", true, "java"),
1354            ("csharp", "csharp", "/tmp/Program.cs", true, "mono"),
1355            ("cs", "csharp", "/tmp/Program.cs", true, "mono"),
1356            ("c#", "csharp", "/tmp/Program.cs", true, "mono"),
1357            ("dotnet", "csharp", "/tmp/Program.cs", true, "mono"),
1358            ("zig", "zig", "/tmp/job.zig", true, "/tmp/job"),
1359        ];
1360
1361        for (alias, expected_key, expected_src, has_compile, expected_run_bin) in cases {
1362            let resolved = registry
1363                .resolve(Some(alias))
1364                .unwrap_or_else(|| panic!("failed to resolve alias '{alias}'"));
1365            assert_eq!(resolved.key, expected_key, "alias '{alias}' key mismatch");
1366            assert_eq!(
1367                resolved.source_file, expected_src,
1368                "alias '{alias}' source mismatch"
1369            );
1370            assert_eq!(
1371                resolved.compile_cmd.is_some(),
1372                has_compile,
1373                "alias '{alias}' compile command presence mismatch"
1374            );
1375            assert_eq!(
1376                resolved.run_cmd[0], expected_run_bin,
1377                "alias '{alias}' run binary mismatch"
1378            );
1379        }
1380    }
1381
1382    #[test]
1383    fn test_language_case_insensitivity_and_whitespace() {
1384        let registry = LanguageRegistry::from_env_or_file();
1385        let cases = [
1386            "  PYTHON  ",
1387            "Rust",
1388            "C++",
1389            "  gO  ",
1390            "Ts",
1391            "  c#  ",
1392            "NODEJS",
1393        ];
1394        for input in cases {
1395            assert!(
1396                registry.resolve(Some(input)).is_some(),
1397                "failed to resolve '{input}'"
1398            );
1399        }
1400    }
1401
1402    #[test]
1403    fn test_unknown_language_returns_none() {
1404        let registry = LanguageRegistry::from_env_or_file();
1405        assert!(registry.resolve(Some("brainfuck")).is_none());
1406        assert!(registry.resolve(Some("unknown_lang")).is_none());
1407        assert!(registry.resolve(Some("nonexistent_language_xyz")).is_none());
1408    }
1409}