Skip to main content

pharos_runtime_core/
stack.rs

1use crate::config::{AppRuntime, ServeConfig};
2
3/// Loaded shared runtime state for the native multi-app host.
4#[derive(Debug, Clone, Default, Eq, PartialEq)]
5pub struct RuntimeStack {
6    pub apps: Vec<AppRuntime>,
7    pub failed_apps: Vec<RuntimeAppLoadFailure>,
8    pub host: Option<String>,
9    pub port: Option<String>,
10    pub socket_path: Option<String>,
11}
12
13/// Recorded failure for one app that could not be loaded into the runtime stack.
14#[derive(Debug, Clone, Default, Eq, PartialEq)]
15pub struct RuntimeAppLoadFailure {
16    pub app_id: String,
17    pub mount_path: String,
18    pub app_root: String,
19    pub artifact_dir: String,
20    pub error: String,
21}
22
23/// Result of loading one app root into the shared runtime stack.
24#[derive(Debug, Clone, Default, Eq, PartialEq)]
25pub struct RuntimeAppLoadOutcome {
26    pub index: usize,
27    pub app: Option<AppRuntime>,
28    pub failure: Option<RuntimeAppLoadFailure>,
29}
30
31/// Resolved inputs for shared runtime stack loading.
32#[derive(Debug, Clone, Eq, PartialEq)]
33pub struct RuntimeStackLoadConfig {
34    pub apps_dir_path: std::path::PathBuf,
35    pub host: Option<String>,
36    pub port: Option<String>,
37    pub socket_path: Option<String>,
38    pub effective_config: ServeConfig,
39}
40
41/// Returns the final path segment as UTF-8 when available.
42pub fn path_last_segment(path: &std::path::Path) -> Option<String> {
43    path.file_name()
44        .and_then(|segment| segment.to_str())
45        .map(ToOwned::to_owned)
46}
47
48/// Returns whether an app root directory basename matches the declared app id.
49pub fn app_directory_matches_id(app_root: &std::path::Path, app_id: &str) -> bool {
50    !app_id.is_empty() && path_last_segment(app_root).as_deref() == Some(app_id)
51}
52
53/// Returns whether the apps directory is itself an app root, which shared runtime loading rejects.
54pub fn apps_dir_has_root_manifest(apps_dir: &std::path::Path) -> bool {
55    apps_dir.join("pharos.app.json").exists() || apps_dir.join("pharos.app.yml").exists()
56}
57
58/// Computes bounded app-load parallelism for startup.
59pub fn app_load_parallelism(app_count: usize, available_parallelism: usize) -> usize {
60    available_parallelism.clamp(1, 8).min(app_count.max(1))
61}
62
63/// Builds one failed app load record from bounded runtime loader context.
64pub fn app_load_failure(
65    app_root: &std::path::Path,
66    app_id: Option<String>,
67    mount_path: Option<String>,
68    artifact_dir: Option<&std::path::Path>,
69    error: &str,
70) -> RuntimeAppLoadFailure {
71    RuntimeAppLoadFailure {
72        app_id: app_id.unwrap_or_default(),
73        mount_path: mount_path.unwrap_or_default(),
74        app_root: app_root.to_string_lossy().into_owned(),
75        artifact_dir: artifact_dir
76            .map(|path| path.to_string_lossy().into_owned())
77            .unwrap_or_default(),
78        error: error.to_owned(),
79    }
80}
81
82/// Resolves the apps directory for a shared runtime config path.
83pub fn runtime_apps_dir(
84    conf_path: Option<&std::path::Path>,
85    current_dir: &std::path::Path,
86) -> Option<String> {
87    let Some(conf_path) = conf_path else {
88        return Some(current_dir.to_string_lossy().into_owned());
89    };
90
91    let raw = crate::config::dynamic_config_lookup_raw(conf_path, "apps_dir");
92    if raw.as_deref().is_none_or(str::is_empty) {
93        return Some(current_dir.to_string_lossy().into_owned());
94    }
95    crate::config::resolve_conf_relative_path(conf_path, raw.as_deref().unwrap_or_default())
96}
97
98/// Resolves a path string relative to a base directory.
99pub fn resolve_path_relative_to(base_dir: &std::path::Path, value: &str) -> Option<String> {
100    if value.is_empty() {
101        return None;
102    }
103    let path = std::path::Path::new(value);
104    if path.is_absolute() {
105        return Some(value.to_owned());
106    }
107    Some(base_dir.join(path).to_string_lossy().into_owned())
108}
109
110fn resolve_value_or_default(value: Option<&str>, fallback: Option<String>) -> Option<String> {
111    value
112        .filter(|value| !value.is_empty())
113        .map(ToOwned::to_owned)
114        .or(fallback)
115}
116
117/// Resolves shared runtime stack loading config from CLI/config-file input.
118pub fn resolve_runtime_stack_load_config(
119    config_record: &ServeConfig,
120    current_dir: &std::path::Path,
121) -> Result<RuntimeStackLoadConfig, &'static str> {
122    let runtime_conf_path = config_record
123        .conf_path
124        .as_deref()
125        .filter(|value| !value.is_empty());
126    let runtime_conf_path = runtime_conf_path.map(|conf_path| {
127        resolve_path_relative_to(current_dir, conf_path).unwrap_or_else(|| conf_path.to_owned())
128    });
129    let runtime_conf_dir = runtime_conf_path.as_deref().and_then(|path| {
130        std::path::Path::new(path)
131            .parent()
132            .map(std::path::Path::to_path_buf)
133    });
134    let runtime_conf_path_ref = runtime_conf_path.as_deref().map(std::path::Path::new);
135
136    let apps_dir =
137        runtime_apps_dir(runtime_conf_path_ref, current_dir).ok_or("missing apps_dir")?;
138    let apps_dir_path = std::path::PathBuf::from(&apps_dir);
139    if !apps_dir_path.is_dir() {
140        return Err("apps_dir missing");
141    }
142
143    let host = resolve_value_or_default(
144        config_record.host.as_deref(),
145        runtime_conf_path_ref
146            .and_then(|path| crate::config::dynamic_config_lookup_raw(path, "host")),
147    )
148    .or_else(|| Some("127.0.0.1".to_owned()));
149    let port = resolve_value_or_default(
150        config_record.port.as_deref(),
151        runtime_conf_path_ref
152            .and_then(|path| crate::config::dynamic_config_lookup_raw(path, "port")),
153    )
154    .or_else(|| Some("7323".to_owned()));
155    let socket_path = if config_record
156        .socket_path
157        .as_deref()
158        .is_some_and(|value| !value.is_empty())
159    {
160        config_record.socket_path.clone()
161    } else if let (Some(runtime_conf_path_ref), Some(runtime_conf_dir)) =
162        (runtime_conf_path_ref, runtime_conf_dir.as_ref())
163    {
164        crate::config::dynamic_config_lookup_raw(runtime_conf_path_ref, "socket_path")
165            .and_then(|value| resolve_path_relative_to(runtime_conf_dir, &value))
166    } else {
167        None
168    };
169
170    let mut effective_config = config_record.clone();
171    if effective_config
172        .build_dir
173        .as_deref()
174        .is_none_or(str::is_empty)
175        && let (Some(runtime_conf_path_ref), Some(runtime_conf_dir)) =
176            (runtime_conf_path_ref, runtime_conf_dir.as_ref())
177        && let Some(raw) =
178            crate::config::dynamic_config_lookup_raw(runtime_conf_path_ref, "build_dir")
179    {
180        effective_config.build_dir = resolve_path_relative_to(runtime_conf_dir, &raw);
181        effective_config.build_dir_from_conf = effective_config.build_dir.is_some();
182    }
183    if effective_config
184        .state_dir
185        .as_deref()
186        .is_none_or(str::is_empty)
187        && let (Some(runtime_conf_path_ref), Some(runtime_conf_dir)) =
188            (runtime_conf_path_ref, runtime_conf_dir.as_ref())
189        && let Some(raw) =
190            crate::config::dynamic_config_lookup_raw(runtime_conf_path_ref, "state_dir")
191    {
192        effective_config.state_dir = resolve_path_relative_to(runtime_conf_dir, &raw);
193        effective_config.state_dir_from_conf = effective_config.state_dir.is_some();
194    }
195    if effective_config
196        .state_path
197        .as_deref()
198        .is_none_or(str::is_empty)
199        && let (Some(runtime_conf_path_ref), Some(runtime_conf_dir)) =
200            (runtime_conf_path_ref, runtime_conf_dir.as_ref())
201        && let Some(raw) =
202            crate::config::dynamic_config_lookup_raw(runtime_conf_path_ref, "state_path")
203    {
204        effective_config.state_path = resolve_path_relative_to(runtime_conf_dir, &raw);
205        effective_config.state_path_from_conf = effective_config.state_path.is_some();
206    }
207    if effective_config
208        .base_path
209        .as_deref()
210        .is_none_or(str::is_empty)
211        && let Some(runtime_conf_path_ref) = runtime_conf_path_ref
212    {
213        effective_config.base_path =
214            crate::config::dynamic_config_lookup_raw(runtime_conf_path_ref, "base_path");
215        effective_config.base_path_from_conf = effective_config
216            .base_path
217            .as_deref()
218            .is_some_and(|value| !value.is_empty());
219    }
220
221    Ok(RuntimeStackLoadConfig {
222        apps_dir_path,
223        host,
224        port,
225        socket_path,
226        effective_config,
227    })
228}
229
230fn json_string(value: &serde_json::Value, key: &str) -> Option<String> {
231    value
232        .get(key)
233        .and_then(serde_json::Value::as_str)
234        .map(ToOwned::to_owned)
235}
236
237fn json_runtime_string(value: &serde_json::Value, key: &str) -> Option<String> {
238    value
239        .get("runtime")
240        .and_then(serde_json::Value::as_object)
241        .and_then(|runtime| runtime.get(key))
242        .and_then(serde_json::Value::as_str)
243        .map(ToOwned::to_owned)
244}
245
246/// Startup timing for one app loaded into the shared runtime stack.
247#[derive(Debug, Clone, Eq, PartialEq)]
248pub struct RuntimeAppStartupTiming {
249    pub app_id: String,
250    pub artifact_dir: std::path::PathBuf,
251    pub load_ms: u128,
252    pub finalize_ms: u128,
253    pub state_ms: u128,
254    pub verify_ms: u128,
255    pub total_ms: u128,
256}
257
258/// App loading services supplied by the root runtime while stack orchestration stays in the core crate.
259pub trait RuntimeAppLoadService: Clone + Send + Sync + 'static {
260    fn read_source_manifest_value(
261        &self,
262        app_root: &std::path::Path,
263    ) -> Result<serde_json::Value, &'static str>;
264
265    fn packaged_artifact_root_matches(&self, app_root: &std::path::Path, target_id: &str) -> bool;
266
267    fn app_runtime_conf_required_present(&self, app_root: &std::path::Path) -> bool;
268
269    fn resolve_build_dir_for_runtime_loading(
270        &self,
271        app_root: &std::path::Path,
272        build_dir: &str,
273        repo_root: Option<&std::path::Path>,
274    ) -> std::path::PathBuf;
275
276    fn load_app_runtime_from_artifact(
277        &self,
278        artifact_dir: &std::path::Path,
279    ) -> Result<AppRuntime, &'static str>;
280
281    fn initialize_missing_app_runtime_state(&self, app: &AppRuntime) -> Result<(), &'static str>;
282
283    fn verify_app_migration_state(&self, app: &AppRuntime) -> Result<(), &'static str>;
284
285    fn log_startup_app_timing(&self, _timing: RuntimeAppStartupTiming) {}
286}
287
288/// Loads one packaged artifact through an injected root artifact parser and shared finalizer.
289pub fn load_packaged_artifact_with_service<S: RuntimeAppLoadService>(
290    service: &S,
291    config_record: &ServeConfig,
292    artifact_root: &std::path::Path,
293) -> Result<(AppRuntime, String), &'static str> {
294    let direct_root = artifact_root.to_path_buf();
295    let direct_build_dir =
296        crate::config::effective_app_build_dir(&direct_root, config_record.build_dir.as_deref())
297            .ok_or("missing build_dir")?;
298    let mut app = service.load_app_runtime_from_artifact(&direct_root)?;
299    crate::config::finalize_loaded_app(
300        &mut app,
301        Some(config_record),
302        Some(&direct_root),
303        Some(std::path::Path::new(&direct_build_dir)),
304    )
305    .map_err(|_| "finalize failed")?;
306    service.initialize_missing_app_runtime_state(&app)?;
307    Ok((app, direct_root.to_string_lossy().into_owned()))
308}
309
310fn load_direct_source_or_artifact_with_service<S: RuntimeAppLoadService>(
311    service: &S,
312    config_record: &ServeConfig,
313    app_root: &std::path::Path,
314    repo_root: Option<&std::path::Path>,
315) -> Result<AppRuntime, &'static str> {
316    let target_id = config_record
317        .target_id
318        .as_deref()
319        .ok_or("missing target_id")?;
320    if !service.app_runtime_conf_required_present(app_root) {
321        return Err("missing app.conf");
322    }
323
324    let build_dir =
325        crate::config::effective_app_build_dir(app_root, config_record.build_dir.as_deref())
326            .ok_or("missing build_dir")?;
327    let resolved_build_dir =
328        service.resolve_build_dir_for_runtime_loading(app_root, &build_dir, repo_root);
329    if service.packaged_artifact_root_matches(app_root, target_id) {
330        return load_packaged_artifact_with_service(service, config_record, app_root)
331            .map(|(app, _)| app);
332    }
333
334    let artifact_dir =
335        crate::config::effective_app_artifact_dir(app_root, &resolved_build_dir, target_id)
336            .ok_or("missing artifact_dir")?;
337    if !std::path::Path::new(&artifact_dir).is_dir() {
338        return Err("artifact_dir missing");
339    }
340
341    let mut app = service.load_app_runtime_from_artifact(std::path::Path::new(&artifact_dir))?;
342    crate::config::finalize_loaded_app(
343        &mut app,
344        Some(config_record),
345        Some(app_root),
346        Some(&resolved_build_dir),
347    )
348    .map_err(|_| "finalize failed")?;
349    service.initialize_missing_app_runtime_state(&app)?;
350    Ok(app)
351}
352
353/// Loads runtime state for one directly targeted app through the shared stack loading service boundary.
354pub fn load_app_runtime_for_single_app_direct_with_service<S: RuntimeAppLoadService>(
355    service: &S,
356    config_record: &ServeConfig,
357    repo_root: Option<&std::path::Path>,
358    binary_dir: Option<&std::path::Path>,
359) -> Result<AppRuntime, &'static str> {
360    let target_id = config_record
361        .target_id
362        .as_deref()
363        .filter(|value| !value.is_empty())
364        .ok_or("missing target_id")?;
365
366    if let Some(app_dir) = config_record.app_dir.as_deref().map(std::path::Path::new) {
367        if service.packaged_artifact_root_matches(app_dir, target_id) {
368            return load_packaged_artifact_with_service(service, config_record, app_dir)
369                .map(|(app, _)| app);
370        }
371        if let Some(manifest) = crate::config::match_app_root_in_directory(app_dir, target_id) {
372            let app_root = std::path::PathBuf::from(manifest);
373            return load_direct_source_or_artifact_with_service(
374                service,
375                config_record,
376                &app_root,
377                repo_root,
378            );
379        }
380        if let Ok(manifest) = service.read_source_manifest_value(app_dir)
381            && json_string(&manifest, "app_id").as_deref() == Some(target_id)
382        {
383            return load_direct_source_or_artifact_with_service(
384                service,
385                config_record,
386                app_dir,
387                repo_root,
388            );
389        }
390    }
391
392    if let Some(app_root) = crate::config::find_app_root_for_target(
393        target_id,
394        config_record.app_dir.as_deref().map(std::path::Path::new),
395        repo_root,
396        binary_dir,
397    ) {
398        return load_direct_source_or_artifact_with_service(
399            service,
400            config_record,
401            std::path::Path::new(&app_root),
402            repo_root,
403        );
404    }
405
406    Err("app target could not be resolved to a packaged artifact")
407}
408
409/// Loads one app root into a runtime stack outcome through the shared stack loading service boundary.
410pub fn load_runtime_stack_app_entry_with_service<S: RuntimeAppLoadService>(
411    service: &S,
412    index: usize,
413    app_root: std::path::PathBuf,
414    effective_config: ServeConfig,
415) -> RuntimeAppLoadOutcome {
416    let failure = |app_id: Option<String>,
417                   mount_path: Option<String>,
418                   artifact_dir: Option<&std::path::Path>,
419                   error: &'static str| {
420        RuntimeAppLoadOutcome {
421            index,
422            app: None,
423            failure: Some(app_load_failure(
424                &app_root,
425                app_id.or_else(|| path_last_segment(&app_root)),
426                mount_path,
427                artifact_dir,
428                error,
429            )),
430        }
431    };
432
433    let manifest = match service.read_source_manifest_value(&app_root) {
434        Ok(manifest) => manifest,
435        Err(_) => return failure(None, None, None, "runtime app manifest unreadable"),
436    };
437    let app_id = match json_string(&manifest, "app_id") {
438        Some(app_id) => app_id,
439        None => {
440            return failure(
441                None,
442                json_runtime_string(&manifest, "base_path"),
443                None,
444                "runtime app manifest missing app_id",
445            );
446        }
447    };
448    let mount_path = json_runtime_string(&manifest, "base_path");
449    if !app_directory_matches_id(&app_root, &app_id) {
450        return failure(
451            Some(app_id),
452            mount_path,
453            None,
454            "runtime app directory does not match app_id",
455        );
456    }
457    if !service.app_runtime_conf_required_present(&app_root) {
458        return failure(
459            Some(app_id),
460            mount_path,
461            None,
462            "runtime app artifact configuration missing",
463        );
464    }
465    let artifact_dir = if service.packaged_artifact_root_matches(&app_root, &app_id) {
466        app_root.clone()
467    } else {
468        let Some(build_dir) = crate::config::effective_app_build_dir(
469            &app_root,
470            effective_config.build_dir.as_deref(),
471        ) else {
472            return failure(Some(app_id), mount_path, None, "missing build_dir");
473        };
474        let resolved_build_dir =
475            service.resolve_build_dir_for_runtime_loading(&app_root, &build_dir, None);
476        let Some(artifact_dir) =
477            crate::config::effective_app_artifact_dir(&app_root, &resolved_build_dir, &app_id)
478        else {
479            return failure(
480                Some(app_id),
481                mount_path,
482                Some(&resolved_build_dir),
483                "missing artifact_dir",
484            );
485        };
486        std::path::PathBuf::from(artifact_dir)
487    };
488    if !artifact_dir.join("pharos.app.json").is_file() {
489        return failure(
490            Some(app_id),
491            mount_path,
492            Some(&artifact_dir),
493            "runtime app artifact manifest missing",
494        );
495    }
496
497    let app_timing_start = std::time::Instant::now();
498    let load_timing_start = std::time::Instant::now();
499    let mut app = match service.load_app_runtime_from_artifact(&artifact_dir) {
500        Ok(app) => app,
501        Err(error) => return failure(Some(app_id), mount_path, Some(&artifact_dir), error),
502    };
503    let load_ms = load_timing_start.elapsed().as_millis();
504    let Some(build_dir) =
505        crate::config::effective_app_build_dir(&app_root, effective_config.build_dir.as_deref())
506    else {
507        return failure(
508            app.app_id.clone(),
509            app.base_path.clone().or(mount_path),
510            Some(&artifact_dir),
511            "missing build_dir",
512        );
513    };
514    let resolved_build_dir =
515        service.resolve_build_dir_for_runtime_loading(&app_root, &build_dir, None);
516    let finalize_timing_start = std::time::Instant::now();
517    if crate::config::finalize_loaded_app(
518        &mut app,
519        Some(&effective_config),
520        Some(&app_root),
521        Some(&resolved_build_dir),
522    )
523    .is_err()
524    {
525        return failure(
526            app.app_id.clone(),
527            app.base_path.clone().or(mount_path),
528            Some(&artifact_dir),
529            "finalize failed",
530        );
531    }
532    let finalize_ms = finalize_timing_start.elapsed().as_millis();
533    let state_timing_start = std::time::Instant::now();
534    if let Err(error) = service.initialize_missing_app_runtime_state(&app) {
535        return failure(
536            app.app_id.clone(),
537            app.base_path.clone().or(mount_path),
538            Some(&artifact_dir),
539            error,
540        );
541    }
542    let state_ms = state_timing_start.elapsed().as_millis();
543    let verify_timing_start = std::time::Instant::now();
544    if let Err(error) = service.verify_app_migration_state(&app) {
545        return failure(
546            app.app_id.clone(),
547            app.base_path.clone().or(mount_path),
548            Some(&artifact_dir),
549            error,
550        );
551    }
552    let verify_ms = verify_timing_start.elapsed().as_millis();
553    service.log_startup_app_timing(RuntimeAppStartupTiming {
554        app_id: app.app_id.as_deref().unwrap_or(&app_id).to_owned(),
555        artifact_dir: artifact_dir.clone(),
556        load_ms,
557        finalize_ms,
558        state_ms,
559        verify_ms,
560        total_ms: app_timing_start.elapsed().as_millis(),
561    });
562    RuntimeAppLoadOutcome {
563        index,
564        app: Some(app),
565        failure: None,
566    }
567}
568
569/// Loads app roots from an apps directory using crate-owned stack orchestration and a root-supplied app service.
570pub fn load_runtime_stack_from_apps_dir_with_service<S: RuntimeAppLoadService>(
571    apps_dir_path: &std::path::Path,
572    host: Option<String>,
573    port: Option<String>,
574    socket_path: Option<String>,
575    effective_config: ServeConfig,
576    service: S,
577    validate_ownership: impl FnOnce(&[AppRuntime]) -> Result<(), &'static str>,
578) -> Result<RuntimeStack, &'static str> {
579    load_runtime_stack_from_apps_dir_with_loader(
580        apps_dir_path,
581        host,
582        port,
583        socket_path,
584        effective_config,
585        move |index, app_root, effective_config| {
586            load_runtime_stack_app_entry_with_service(&service, index, app_root, effective_config)
587        },
588        validate_ownership,
589    )
590}
591
592/// Loads app roots from an apps directory using a root-supplied app loader.
593pub fn load_runtime_stack_from_apps_dir(
594    apps_dir_path: &std::path::Path,
595    host: Option<String>,
596    port: Option<String>,
597    socket_path: Option<String>,
598    effective_config: ServeConfig,
599    load_entry: fn(usize, std::path::PathBuf, ServeConfig) -> RuntimeAppLoadOutcome,
600    validate_ownership: impl FnOnce(&[AppRuntime]) -> Result<(), &'static str>,
601) -> Result<RuntimeStack, &'static str> {
602    load_runtime_stack_from_apps_dir_with_loader(
603        apps_dir_path,
604        host,
605        port,
606        socket_path,
607        effective_config,
608        load_entry,
609        validate_ownership,
610    )
611}
612
613fn load_runtime_stack_from_apps_dir_with_loader<F>(
614    apps_dir_path: &std::path::Path,
615    host: Option<String>,
616    port: Option<String>,
617    socket_path: Option<String>,
618    effective_config: ServeConfig,
619    load_entry: F,
620    validate_ownership: impl FnOnce(&[AppRuntime]) -> Result<(), &'static str>,
621) -> Result<RuntimeStack, &'static str>
622where
623    F: Fn(usize, std::path::PathBuf, ServeConfig) -> RuntimeAppLoadOutcome
624        + Clone
625        + Send
626        + Sync
627        + 'static,
628{
629    if !apps_dir_path.is_dir() {
630        return Err("apps_dir missing");
631    }
632    if apps_dir_has_root_manifest(apps_dir_path) {
633        return Err("apps_dir contains a root-level app manifest");
634    }
635
636    let mut app_roots: Vec<std::path::PathBuf> = std::fs::read_dir(apps_dir_path)
637        .map_err(|_| "apps_dir unreadable")?
638        .flatten()
639        .map(|entry| entry.path())
640        .filter(|path| path.is_dir())
641        .collect();
642    app_roots.sort_by(|left, right| {
643        path_last_segment(left)
644            .unwrap_or_default()
645            .cmp(&path_last_segment(right).unwrap_or_default())
646    });
647
648    let parallelism = app_load_parallelism(
649        app_roots.len(),
650        std::thread::available_parallelism()
651            .map(usize::from)
652            .unwrap_or(1),
653    );
654    let mut outcomes = Vec::with_capacity(app_roots.len());
655    for chunk in app_roots.chunks(parallelism) {
656        let mut handles = Vec::with_capacity(chunk.len());
657        for app_root in chunk {
658            let index = outcomes.len() + handles.len();
659            let app_root = app_root.clone();
660            let effective_config = effective_config.clone();
661            let load_entry = load_entry.clone();
662            handles.push(std::thread::spawn(move || {
663                load_entry(index, app_root, effective_config)
664            }));
665        }
666        for handle in handles {
667            match handle.join() {
668                Ok(outcome) => outcomes.push(outcome),
669                Err(_) => outcomes.push(RuntimeAppLoadOutcome {
670                    index: outcomes.len(),
671                    app: None,
672                    failure: Some(RuntimeAppLoadFailure {
673                        app_id: "unknown".to_owned(),
674                        app_root: String::new(),
675                        mount_path: String::new(),
676                        artifact_dir: String::new(),
677                        error: "runtime app loader panicked".to_owned(),
678                    }),
679                }),
680            }
681        }
682    }
683
684    outcomes.sort_by_key(|outcome| outcome.index);
685    let mut stack = RuntimeStack {
686        apps: Vec::new(),
687        failed_apps: Vec::new(),
688        host,
689        port,
690        socket_path,
691    };
692    for outcome in outcomes {
693        if let Some(app) = outcome.app {
694            stack.apps.push(app);
695        }
696        if let Some(failure) = outcome.failure {
697            stack.failed_apps.push(failure);
698        }
699    }
700
701    if stack.apps.is_empty() {
702        for failure in &stack.failed_apps {
703            eprintln!(
704                "runtime app load failed: app_id={} error={}",
705                failure.app_id, failure.error
706            );
707        }
708        return Err("no runtime apps loaded");
709    }
710    validate_ownership(&stack.apps)?;
711    Ok(stack)
712}
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    #[test]
719    fn runtime_stack_records_failed_apps() {
720        let stack = RuntimeStack {
721            failed_apps: vec![RuntimeAppLoadFailure {
722                app_id: "broken_app".to_owned(),
723                mount_path: "/broken".to_owned(),
724                app_root: "apps/broken".to_owned(),
725                artifact_dir: "dist/apps/broken".to_owned(),
726                error: "missing artifact".to_owned(),
727            }],
728            ..RuntimeStack::default()
729        };
730        assert_eq!(stack.failed_apps[0].app_id, "broken_app");
731        assert_eq!(stack.failed_apps[0].mount_path, "/broken");
732    }
733
734    #[test]
735    fn app_load_parallelism_is_bounded() {
736        assert_eq!(app_load_parallelism(0, 0), 1);
737        assert_eq!(app_load_parallelism(1, 64), 1);
738        assert_eq!(app_load_parallelism(10, 64), 8);
739        assert_eq!(app_load_parallelism(3, 2), 2);
740    }
741
742    #[test]
743    fn app_load_failure_records_paths() {
744        let failure = app_load_failure(
745            std::path::Path::new("apps/demo"),
746            Some("demo".to_owned()),
747            Some("/demo".to_owned()),
748            Some(std::path::Path::new("dist/apps/demo")),
749            "missing artifact",
750        );
751        assert_eq!(failure.app_id, "demo");
752        assert_eq!(failure.mount_path, "/demo");
753        assert_eq!(failure.artifact_dir, "dist/apps/demo");
754    }
755
756    #[test]
757    fn runtime_apps_dir_defaults_to_current_dir() {
758        let current = std::path::Path::new("/tmp/pharos-apps");
759        assert_eq!(
760            runtime_apps_dir(None, current).as_deref(),
761            Some("/tmp/pharos-apps")
762        );
763    }
764
765    fn test_loader(
766        index: usize,
767        app_root: std::path::PathBuf,
768        _config: ServeConfig,
769    ) -> RuntimeAppLoadOutcome {
770        RuntimeAppLoadOutcome {
771            index,
772            app: Some(AppRuntime {
773                app_id: path_last_segment(&app_root),
774                app_root: Some(app_root.to_string_lossy().into_owned()),
775                base_path: Some(format!("/{index}")),
776                ..AppRuntime::default()
777            }),
778            failure: None,
779        }
780    }
781
782    #[test]
783    fn stack_loader_merges_ordered_outcomes() {
784        let root = std::env::temp_dir().join(format!("pharos-stack-loader-{}", std::process::id()));
785        let _ = std::fs::remove_dir_all(&root);
786        std::fs::create_dir_all(root.join("b_app")).expect("b app dir");
787        std::fs::create_dir_all(root.join("a_app")).expect("a app dir");
788        let stack = load_runtime_stack_from_apps_dir(
789            &root,
790            Some("127.0.0.1".to_owned()),
791            Some("7323".to_owned()),
792            None,
793            ServeConfig::default(),
794            test_loader,
795            |_| Ok(()),
796        )
797        .expect("stack load");
798        assert_eq!(stack.apps[0].app_id.as_deref(), Some("a_app"));
799        assert_eq!(stack.apps[1].app_id.as_deref(), Some("b_app"));
800        let _ = std::fs::remove_dir_all(root);
801    }
802}