Skip to content

Commit 6ca5684

Browse files
committed
feat: lazy-load priority extraction
--lazy-load previously failed at runtime: priorities used absolute /app paths that never matched tar's payload-relative entries (and listed the host interpreter as a payload file). Priorities are now payload-relative, the entrypoint script is always a priority while the interpreter never is, and --lazy-priority / [build] lazy_priority add app-relative or payload-relative specs (dirs expand recursively, 2048 cap) via daedalus-core::lazy::expand_priority_paths. Fixes: non-priority tar entries are drained before skipping (stream misalignment caused 'Invalid argument (os error 22)'), and namespaced builds (isolation >= 2) extract synchronously -- spawning any thread makes the later unshare(CLONE_NEWUSER|CLONE_NEWNS) fail EINVAL and pivot_root strands a bg thread's cache path. Small hosts (< 4 cores, < 3 GiB) auto-disable lazy-load without an explicit priority list.
1 parent 8e48285 commit 6ca5684

14 files changed

Lines changed: 730 additions & 70 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
77
## [Unreleased]
88

99
### Added
10+
- **Lazy-loading priority extraction**: `daedalus build --lazy-load` now fast-starts by extracting only the entrypoint (plus an explicit `--lazy-priority file,dir,...` list or `[build] lazy_priority`) before launching, then fills the rest of the app tree in the background. Priority specs may be app-relative (`main.py`) or payload-relative (`app/main.py`); directories expand recursively (skipping `__pycache__`/`.git`), capped at 2048 files. Dry-run reports `Lazy:` on/off and the priority count. Namespaced builds (isolation ≥ 2) extract synchronously so the user-namespace `unshare` stays single-threaded.
11+
- `daedalus-core::lazy::expand_priority_paths` — build-time expansion of `--lazy-priority` specs into payload-relative paths with tar-exclusion parity
12+
- Constrained-host auto-disable: `--lazy-load` without an explicit priority list turns itself off on small hosts (< 4 cores or < 3 GiB RAM, via `daedalus-core::system_info::is_constrained_host`)
13+
- `daedalus inspect` reports the `lazy_priority` metadata (`Lazy priority:` line)
1014
- **GPU passthrough for accelerated inference**: `daedalus build --gpu auto|nvidia|rocm|none` (or `[build] gpu` in `.daedalus.toml`) records the compute backend in the binary metadata. When `nvidia` or `rocm` is set, the launcher bind-mounts the GPU device nodes (NVIDIA `/dev/nvidia*` + caps, ROCm `/dev/kfd` + `/dev/dri` render nodes) into the sandbox via a regular-file overlay and pins the matching visibility vars (`CUDA_VISIBLE_DEVICES`, `NVIDIA_VISIBLE_DEVICES`, `HIP_VISIBLE_DEVICES`, `ROCR_VISIBLE_DEVICES`) — Ollama/llama.cpp offload works headless, inside the sandbox.
1115
- `daedalus-core::gpu` detection probe (`--gpu auto`): `/dev/kfd` → ROCm, `/proc/driver/nvidia/gpus` → NVIDIA; CPU fallback is safe (missing nodes are skipped)
1216
- `daedalus inspect` shows the embedded `GPU:` backend; dry-run reports the resolved backend
@@ -18,6 +22,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
1822
- System config detection module `daedalus-core/src/system_info.rs` (`SystemConfig`, `detect()`, `compute_universal_slices()`) for resource-adaptive builds
1923

2024
### Fixed
25+
- **`--lazy-load` runtime failure**: priority files used absolute `/app/...` paths that never matched the tar's payload-relative entries (and wrongly listed the host PATH interpreter as a payload file), so every lazy build errored with `priority files not found in payload: /app/python3`. Priorities are now payload-relative and the interpreter is never one.
26+
- Stub lazy extraction skipped non-priority tar entries without draining them, misaligning the stream and breaking extraction with `Invalid argument (os error 22)` whenever a non-priority entry was followed by more data — entries are now drained before skipping
2127
- Stub multi-service supervisor on Unix now execs via `execvp` (PATH search) instead of `execve`, so bare interpreter names like `python3` resolve at runtime — multi-service binaries can actually launch their services
2228
- `find_in_bin_paths`/`is_executable_path` made cross-platform (were Windows-only), so the Unix supervisor shares the same interpreter-resolution fallback as the Windows and single-service paths
2329
- Removed unused `libc_execve` FFI declaration and `env_to_cstrings` helper

‎daedalus-cli/src/commands/build/args.rs‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@ pub(crate) struct BuildPlan {
8989
/// Identifier of the AI model bundled for offline inference (derived from
9090
/// `--model` filename). Exposed in the build metadata as `model_id`.
9191
pub(crate) model_id: Option<String>,
92+
/// Payload-relative paths to eager-extract when lazy loading is enabled
93+
/// (from `--lazy-priority` or `[build] lazy_priority`).
94+
pub(crate) lazy_priority: Vec<String>,
9295
}
9396

9497
/// Compute backend selection for `--gpu`.
@@ -396,6 +399,10 @@ pub(crate) fn config_fingerprint(args: &BuildArgs, plan: &BuildPlan) -> String {
396399
.unwrap_or_else(|_| "unreadable".to_string());
397400
canonical.push(format!("env_file={}:{content}", v.display()));
398401
}
402+
canonical.push(format!("lazy_load={}", args.lazy_load));
403+
if !plan.lazy_priority.is_empty() {
404+
canonical.push(format!("lazy_priority={:?}", plan.lazy_priority));
405+
}
399406
let mut env: Vec<&String> = args.env.iter().collect();
400407
env.sort();
401408
for e in env {
@@ -445,6 +452,23 @@ pub(crate) fn config_fingerprint(args: &BuildArgs, plan: &BuildPlan) -> String {
445452
hex::encode(hasher.finalize())[..16].to_string()
446453
}
447454

455+
/// Whether lazy loading is effective for this build.
456+
///
457+
/// Background decompression steals CPU from the app on a constrained
458+
/// host, so `--lazy` without an explicit priority list is auto-
459+
/// disabled there. An explicit `--lazy-priority` (or `[build]
460+
/// lazy_priority`) overrides the auto-disable because the operator
461+
/// asked for it deliberately.
462+
pub(crate) fn lazy_enabled(lazy_load: bool, lazy_priority: &[String]) -> bool {
463+
if !lazy_load {
464+
return false;
465+
}
466+
if !lazy_priority.is_empty() {
467+
return true;
468+
}
469+
!daedalus_core::system_info::is_constrained_host(&daedalus_core::system_info::detect())
470+
}
471+
448472
#[derive(Args)]
449473
#[command(after_help = "\
450474
Examples:
@@ -713,6 +737,12 @@ pub struct BuildArgs {
713737
#[arg(long)]
714738
pub lazy_load: bool,
715739

740+
/// Paths to eager-extract when lazy loading is enabled. App-relative
741+
/// (`main.py`) or payload-relative (`app/main.py`); a directory expands
742+
/// to every file under it. Comma-separated or repeated.
743+
#[arg(long, value_delimiter = ',')]
744+
pub lazy_priority: Vec<String>,
745+
716746
/// Bundle an AI model (`.gguf`) into the binary for offline inference.
717747
///
718748
/// Copies the file into the rootfs at `app/models/<name>` and forces the
@@ -747,6 +777,9 @@ pub(crate) struct BuildConfig {
747777
/// Identifier of the AI model to bundle for offline inference, set in
748778
/// `.daedalus.toml` as `[build] model_id = "gemma-2b-it-q4"`.
749779
pub model_id: Option<String>,
780+
/// Paths to eager-extract when lazy loading is enabled, set in
781+
/// `.daedalus.toml` as `[build] lazy_priority = ["main.py", "models/"]`.
782+
pub lazy_priority: Option<Vec<String>>,
750783
}
751784

752785
#[derive(Default, Deserialize)]
@@ -835,6 +868,7 @@ pub(crate) fn default_build_args() -> BuildArgs {
835868
token: None,
836869
universal: false,
837870
lazy_load: false,
871+
lazy_priority: Vec::new(),
838872
encrypt: None,
839873
model: None,
840874
}
@@ -1128,6 +1162,7 @@ mod tests {
11281162
services: Vec::new(),
11291163
entrypoint: Vec::new(),
11301164
model_id: None,
1165+
lazy_priority: Vec::new(),
11311166
};
11321167
let base = config_fingerprint(&default_build_args(), &plan(false));
11331168

@@ -1202,4 +1237,27 @@ mod tests {
12021237
let err = apply_service_overrides(&mut services, &["ghost=8080".into()], &[] as &[String]);
12031238
assert!(err.is_err(), "unknown service name must fail");
12041239
}
1240+
1241+
#[test]
1242+
/// lazy_enabled_requires_the_flag - lazy enabled requires the flag.
1243+
///
1244+
/// Description:
1245+
///
1246+
/// Return: nothing
1247+
fn lazy_enabled_requires_the_flag() {
1248+
assert!(!lazy_enabled(false, &[]));
1249+
assert!(!lazy_enabled(false, &["main.py".into()]));
1250+
}
1251+
1252+
#[test]
1253+
/// lazy_enabled_honors_explicit_priority - lazy enabled honors explicit priority.
1254+
///
1255+
/// Description:
1256+
/// An operator-supplied priority list keeps lazy loading on even on a
1257+
/// host too small for background extraction.
1258+
///
1259+
/// Return: nothing
1260+
fn lazy_enabled_honors_explicit_priority() {
1261+
assert!(lazy_enabled(true, &["main.py".into()]));
1262+
}
12051263
}

‎daedalus-cli/src/commands/build/mod.rs‎

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ pub fn run(args: BuildArgs, verbose: bool) -> Result<()> {
4747
// below, so the helper can borrow-build the id without tripping the
4848
// borrow checker on the whole-struct reference.
4949
let model_id_from_config = config.build.model_id.clone();
50+
let lazy_priority_from_config = config.build.lazy_priority.clone();
5051
let (runtime, model_id) =
5152
resolve_build_runtime(&app_dir, &args, model_id_from_config, verbose)?;
5253
let runtime_name = runtime.name().to_string();
@@ -98,6 +99,7 @@ pub fn run(args: BuildArgs, verbose: bool) -> Result<()> {
9899
services,
99100
entrypoint: entrypoint_args,
100101
model_id,
102+
lazy_priority: resolve_lazy_priority(&args, lazy_priority_from_config),
101103
};
102104

103105
if args.dry_run {
@@ -143,13 +145,22 @@ pub fn run(args: BuildArgs, verbose: bool) -> Result<()> {
143145
Ok(())
144146
}
145147

148+
/// CLI flags win over `[build] lazy_priority` from config.
149+
fn resolve_lazy_priority(args: &BuildArgs, from_config: Option<Vec<String>>) -> Vec<String> {
150+
if args.lazy_priority.is_empty() {
151+
from_config.unwrap_or_default()
152+
} else {
153+
args.lazy_priority.clone()
154+
}
155+
}
156+
146157
/// Resolve the build runtime and model id.
147158
///
148159
/// `--model` implies an offline Gemma bundle: embedding weights for local
149-
/// inference pins the runtime to Gemma regardless of the source layout, so the
150-
/// stub serves the model from the bundled `.gguf`. The model id is derived from
151-
/// the `--model` filename (stable across rebuilds of the same weights) and falls
152-
/// back to `[build] model_id` from `.daedalus.toml`.
160+
/// inference pins the runtime to Gemma regardless of the source layout, so
161+
/// the stub serves the model from the bundled `.gguf`. The model id is derived
162+
/// from the `--model` filename (stable across rebuilds of the same weights)
163+
/// and falls back to `[build] model_id` from `.daedalus.toml`.
153164
fn resolve_build_runtime(
154165
app_dir: &Path,
155166
args: &BuildArgs,
@@ -283,6 +294,23 @@ fn expand_path(path: &str) -> std::path::PathBuf {
283294
/// Description:
284295
///
285296
/// Return: nothing
297+
/// Reference detection + install lines for the dry-run summary.
298+
fn pkgmgr_lines(plan: &BuildPlan) -> Vec<String> {
299+
let mgrs = daedalus_core::pkgmgr::detect_all_pkgmgrs(&plan.app_dir, &plan.runtime_name);
300+
if mgrs.is_empty() {
301+
return vec!["Pkg mgr: (none)".into()];
302+
}
303+
let mut lines = Vec::new();
304+
for mgr in mgrs {
305+
lines.push(format!("Pkg mgr: {}", mgr.name()));
306+
if !plan.no_install {
307+
lines.push(format!("Install: {}", mgr.install_cmd().join(" ")));
308+
}
309+
}
310+
lines
311+
}
312+
313+
/// Print what a build would do without touching the app directory.
286314
fn print_dry_run(args: &BuildArgs, plan: &BuildPlan, target: Option<&str>, output: &Path) {
287315
eprintln!("Dry run — would build:");
288316
eprintln!(" App: {}", plan.app_dir.display());
@@ -331,6 +359,11 @@ fn print_dry_run(args: &BuildArgs, plan: &BuildPlan, target: Option<&str>, outpu
331359
if let Some(ref e) = plan.env_file {
332360
eprintln!(" Env file: {}", e.display());
333361
}
362+
let lazy_on = args::lazy_enabled(args.lazy_load, &plan.lazy_priority);
363+
eprintln!(" Lazy: {}", if lazy_on { "on" } else { "off" });
364+
if lazy_on {
365+
eprintln!(" Lazy priority: {} files", plan.lazy_priority.len());
366+
}
334367
if plan.no_install {
335368
eprintln!(" No install: yes");
336369
}
@@ -367,18 +400,8 @@ fn print_dry_run(args: &BuildArgs, plan: &BuildPlan, target: Option<&str>, outpu
367400
eprintln!(" Services: {}", describe_services(plan));
368401
}
369402

370-
// Detect package managers
371-
let all_mgrs = daedalus_core::pkgmgr::detect_all_pkgmgrs(&plan.app_dir, &plan.runtime_name);
372-
if all_mgrs.is_empty() {
373-
eprintln!(" Pkg mgr: (none)");
374-
} else {
375-
for mgr in &all_mgrs {
376-
eprintln!(" Pkg mgr: {}", mgr.name());
377-
if !plan.no_install {
378-
let cmd = mgr.install_cmd();
379-
eprintln!(" Install: {}", cmd.join(" "));
380-
}
381-
}
403+
for line in pkgmgr_lines(plan) {
404+
eprintln!(" {line}");
382405
}
383406

384407
// Estimate sizes

‎daedalus-cli/src/commands/build/pipeline.rs‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use daedalus_core::detect;
44
use daedalus_core::embed::{self, RuntimeProfile};
55
use daedalus_core::encrypt;
66
use daedalus_core::layer::{Capability, RuntimeLayer, SerializableLayer};
7+
use daedalus_core::lazy;
78
use daedalus_core::metadata::{BunFeatures, EmbeddedInterpreter};
89
use daedalus_core::paths::cache_dir;
910
use daedalus_core::pkgmgr;
@@ -285,6 +286,18 @@ pub(crate) fn build_single_target(
285286
None
286287
};
287288

289+
let lazy_on = super::args::lazy_enabled(args.lazy_load, &plan.lazy_priority);
290+
let lazy_priority = if lazy_on {
291+
if plan.lazy_priority.is_empty() {
292+
Vec::new()
293+
} else {
294+
lazy::expand_priority_paths(&plan.lazy_priority, &rootfs)
295+
.with_context(|| "invalid --lazy-priority; give paths relative to the app dir")?
296+
}
297+
} else {
298+
Vec::new()
299+
};
300+
288301
let meta = daedalus_core::assembly::build_meta_json(
289302
&app_name,
290303
runtime_name,
@@ -311,7 +324,8 @@ pub(crate) fn build_single_target(
311324
post_hooks,
312325
layers: Some(layers),
313326
entrypoint_layer: Some(runtime_name.clone()),
314-
lazy_load: args.lazy_load,
327+
lazy_load: lazy_on,
328+
lazy_priority,
315329
mcp_tools,
316330
model_id: plan.model_id.clone(),
317331
services: plan

‎daedalus-cli/tests/cli_tests.rs‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,53 @@ fn test_build_dry_run_gpu_default_is_cpu() {
266266
.stderr(predicate::str::contains("GPU: none (CPU)"));
267267
}
268268

269+
#[test]
270+
fn test_build_help_lists_lazy_priority_flag() {
271+
daedalus()
272+
.args(["build", "--help"])
273+
.assert()
274+
.success()
275+
.stdout(predicate::str::contains("--lazy-priority"));
276+
}
277+
278+
#[test]
279+
fn test_build_dry_run_shows_lazy_priority() {
280+
let dir = tempdir().unwrap();
281+
std::fs::create_dir_all(dir.path().join("app")).unwrap();
282+
std::fs::write(dir.path().join("app/package.json"), "{\"name\":\"app\"}").unwrap();
283+
daedalus()
284+
.args([
285+
"build",
286+
dir.path().join("app").to_str().unwrap(),
287+
"--lazy-load",
288+
"--lazy-priority",
289+
"main.py,config.json",
290+
"--dry-run",
291+
"--no-install",
292+
])
293+
.assert()
294+
.success()
295+
.stderr(predicate::str::contains("Lazy: on"))
296+
.stderr(predicate::str::contains("Lazy priority: 2"));
297+
}
298+
299+
#[test]
300+
fn test_build_dry_run_lazy_off_by_default() {
301+
let dir = tempdir().unwrap();
302+
std::fs::create_dir_all(dir.path().join("app")).unwrap();
303+
std::fs::write(dir.path().join("app/package.json"), "{\"name\":\"app\"}").unwrap();
304+
daedalus()
305+
.args([
306+
"build",
307+
dir.path().join("app").to_str().unwrap(),
308+
"--dry-run",
309+
"--no-install",
310+
])
311+
.assert()
312+
.success()
313+
.stderr(predicate::str::contains("Lazy: off"));
314+
}
315+
269316
#[test]
270317
fn test_build_dry_run_rejects_unknown_service_port() {
271318
let dir = tempdir().unwrap();

0 commit comments

Comments
 (0)