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 ("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}