Skip to main content

thruput_node_supervisor/
hogd_dev.rs

1//! hog-devnode process adapter.
2
3use sha2::{Digest, Sha256};
4use std::{
5    collections::BTreeMap,
6    env,
7    ffi::OsString,
8    fs::{self, File},
9    io::{self, Read},
10    net::SocketAddr,
11    path::{Path, PathBuf},
12    process::ExitStatus,
13    time::Duration,
14};
15use thruput_json_rpc_types::base58::FixedIdentifier;
16
17use serde::{Deserialize, de::DeserializeOwned};
18use tempfile::TempDir;
19use thruput_json_rpc_types::{
20    ContractAddress, NetworkHashText,
21    hex::HexBytes,
22    runtime_manifest::{
23        BOOTSTRAP_ACCOUNT_ROLE_FAUCET, BOOTSTRAP_ACCOUNT_ROLE_LOCAL_USER,
24        HOGD_DEV_RUNTIME_MANIFEST_KIND, RuntimeManifest, RuntimeManifestChainState,
25        parse_runtime_manifest,
26    },
27};
28use thruput_signing::LocalSigner;
29use thruput_tx_validation::ConfigParamName;
30use tokio::{
31    io::{AsyncBufRead, AsyncBufReadExt, BufReader},
32    process::{ChildStderr, ChildStdout},
33    task::JoinHandle,
34    time,
35};
36
37use crate::{
38    ChildProcessSpec, ChildSupervisor, HogdDevConfig, NodeSupervisorError, SupervisedChild,
39    local_state::{LocalChainState, preflight_state_version, state_error},
40    pid_file::PidGuard,
41};
42
43/// Default devnode binary name.
44pub const DEFAULT_HOGD_DEV_BINARY: &str = "hog-devnode";
45
46/// Legacy devnode binary name accepted during the rename transition.
47pub const LEGACY_HOGD_DEV_BINARY: &str = "hogd-dev";
48
49/// Environment variable used to override the devnode binary path.
50pub const DEVNODE_BINARY_ENV: &str = "THRUPUT_DEVNODE_BINARY";
51
52/// Environment variable used to request structured JSON logs from hogd-dev.
53pub const HOGD_LOG_FORMAT_ENV: &str = "HOGD_LOG_FORMAT";
54
55/// Maximum accepted hog-devnode readiness line length.
56pub const MAX_READINESS_LINE_BYTES: usize = 64 * 1024;
57
58/// Maximum accepted hogd-dev stderr log line length.
59pub const MAX_LOG_LINE_BYTES: usize = 64 * 1024;
60
61/// Maximum accepted runtime manifest size.
62pub const MAX_RUNTIME_MANIFEST_BYTES: usize = 1024 * 1024;
63
64/// Bootstrap-account derivation contract supported by the Rust dev tooling.
65pub const SUPPORTED_BOOTSTRAP_ACCOUNT_DERIVATION_SCHEME: &str = "thruput-bootstrap-account-v1";
66
67const BOOTSTRAP_MANIFEST_FILE_NAME: &str = "bootstrap-manifest.json";
68const PERSISTENT_PID_FILE_NAME: &str = "hog-devnode.pid";
69
70/// Ready endpoints exposed by hogd-dev.
71#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
72#[non_exhaustive]
73pub struct HogdDevEndpoints {
74    /// `SchedulerCoord` endpoint. Also accepts hogd-dev's compact
75    /// readiness key `scheduler`.
76    ///
77    /// Required for genesis and local block driving.
78    #[serde(alias = "scheduler")]
79    pub scheduler_coord: SocketAddr,
80    /// `SchedGateway` endpoint for public submit and read-side scheduler probes.
81    /// Absent only in legacy readiness payloads without a runtime manifest.
82    #[serde(default)]
83    pub scheduler_gateway: Option<SocketAddr>,
84    /// `IndexerBlockExplorer` endpoint. Also accepts hogd-dev's compact
85    /// readiness key `read`. Absent in the reduced contract.
86    #[serde(default, alias = "read")]
87    pub indexer_block_explorer: Option<SocketAddr>,
88    /// `DbGateway` endpoint for latest-state storage reads. Also accepts
89    /// hogd-dev's compact readiness key `gateway`.
90    #[serde(default, alias = "gateway")]
91    pub db_gateway: Option<SocketAddr>,
92    /// `ViewGateway` endpoint for sandboxed contract simulation. Also accepts
93    /// hogd-dev's compact readiness key `view`.
94    #[serde(default, alias = "view")]
95    pub view_gateway: Option<SocketAddr>,
96}
97
98/// Public native account metadata advertised by hog-devnode readiness.
99#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
100#[non_exhaustive]
101pub(crate) struct HogdDevReadyAccount {
102    /// Runtime list position used only for internal diagnostics.
103    pub index: u32,
104    /// Infrastructure or local-user role advertised by the runtime manifest.
105    pub role: String,
106    /// Wallet contract address bootstrapped by hogd-dev.
107    pub address: ContractAddress,
108    /// Ed25519 public key authorized by the wallet contract.
109    pub public_key: HexBytes,
110    /// Storage-token balance at bootstrap; not refreshed thereafter.
111    pub initial_storage_tokens: u64,
112}
113
114/// hog-devnode readiness payload.
115#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
116#[non_exhaustive]
117pub struct HogdDevReadiness {
118    /// Readiness status. Must be `ready`.
119    pub status: String,
120    /// Whether hog-devnode initialized a new chain or recovered a committed chain.
121    #[serde(default)]
122    pub chain_state: Option<RuntimeManifestChainState>,
123    /// Unprefixed 32-byte local chain network hash.
124    pub network_hash: NetworkHashText,
125    /// x23 protocol token expected by hogd-dev services.
126    #[serde(default)]
127    pub protocol: Option<String>,
128    /// x23 endpoints available after startup.
129    pub endpoints: HogdDevEndpoints,
130    /// Public native account metadata advertised by hogd-dev.
131    #[serde(default)]
132    pub(crate) dev_accounts: Vec<HogdDevReadyAccount>,
133    /// Full runtime manifest loaded from the advertised readiness pointer.
134    #[serde(default)]
135    pub runtime_manifest: Option<RuntimeManifest>,
136    /// Runtime manifest file path advertised by hog-devnode readiness.
137    #[serde(default)]
138    pub manifest_path: Option<PathBuf>,
139    /// SHA-256 hash of the exact runtime manifest file bytes.
140    #[serde(default)]
141    pub manifest_hash: Option<String>,
142}
143
144impl HogdDevReadiness {
145    fn ensure_ready(self, program: &str) -> Result<Self, NodeSupervisorError> {
146        if self.status == "ready" {
147            return Ok(self);
148        }
149        Err(NodeSupervisorError::InvalidReadinessStatus {
150            program: program.to_string(),
151            status: self.status,
152        })
153    }
154}
155
156#[derive(Debug, Deserialize)]
157#[serde(rename_all = "camelCase", deny_unknown_fields)]
158struct HogdDevReadinessPointer {
159    schema_version: u32,
160    status: String,
161    manifest_path: PathBuf,
162    manifest_hash: String,
163}
164
165#[derive(Debug, Deserialize)]
166#[serde(rename_all = "camelCase")]
167struct HogdDevReadinessEnvelope {
168    schema_version: u32,
169}
170
171/// Validate readiness accounts against explicit public bootstrap allocations.
172pub(crate) fn validate_bootstrap_accounts(
173    readiness: &HogdDevReadiness,
174    config: &HogdDevConfig,
175    network_hash: &NetworkHashText,
176    faucet_public_key: thruput_json_rpc_types::Ed25519PublicKey,
177) -> Result<(), NodeSupervisorError> {
178    let fail = |reason: String| NodeSupervisorError::InvalidRuntimeManifest {
179        program: DEFAULT_HOGD_DEV_BINARY.to_owned(),
180        reason,
181    };
182    let manifest = readiness.runtime_manifest.as_ref().ok_or_else(|| {
183        fail("hog-devnode readiness must advertise a schema-4 runtime manifest".into())
184    })?;
185    let expected = config.to_bootstrap_manifest(network_hash.clone(), faucet_public_key);
186    if manifest.bootstrap_accounts.len() != expected.bootstrap_accounts.len() + 1 {
187        return Err(fail(
188            "runtime bootstrap allocations do not match public intent".into(),
189        ));
190    }
191    let mut found = std::collections::HashSet::new();
192    for allocation in
193        std::iter::once(&expected.bootstrap_faucet).chain(expected.bootstrap_accounts.iter())
194    {
195        let account = manifest
196            .bootstrap_accounts
197            .iter()
198            .find(|account| account.public_key == allocation.public_key)
199            .ok_or_else(|| fail("runtime is missing an explicitly allocated public key".into()))?;
200        if !found.insert(account.public_key.as_ref().to_vec()) {
201            return Err(fail("duplicate bootstrap public key".into()));
202        }
203        let role = if account.public_key == expected.bootstrap_faucet.public_key {
204            BOOTSTRAP_ACCOUNT_ROLE_FAUCET
205        } else {
206            BOOTSTRAP_ACCOUNT_ROLE_LOCAL_USER
207        };
208        let public_key: [u8; 32] = account
209            .public_key
210            .as_ref()
211            .try_into()
212            .map_err(|_| fail("invalid bootstrap public key width".into()))?;
213        if account.role != role
214            || account.initial_storage_tokens != allocation.initial_storage_tokens
215            || account.wallet_address.0
216                != thruput_signing::PubkeyAccount::from_public_key_bytes(public_key, 0).address()
217        {
218            return Err(fail("runtime bootstrap account does not match its public allocation and native identity".into()));
219        }
220    }
221    Ok(())
222}
223
224pub(crate) fn validate_runtime_identity(
225    readiness: &HogdDevReadiness,
226    expected_protocol: &str,
227    expected_network_hash: &NetworkHashText,
228) -> Result<(), NodeSupervisorError> {
229    if readiness.network_hash != *expected_network_hash {
230        return Err(NodeSupervisorError::RuntimeNetworkHashMismatch {
231            expected: expected_network_hash.clone(),
232            observed: readiness.network_hash.clone(),
233        });
234    }
235    if readiness.protocol.as_deref() != Some(expected_protocol) {
236        return Err(NodeSupervisorError::RuntimeProtocolMismatch {
237            expected: expected_protocol.to_string(),
238            observed: readiness.protocol.clone(),
239        });
240    }
241    Ok(())
242}
243
244/// Running hogd-dev child process plus its parsed readiness metadata.
245#[derive(Debug)]
246pub struct HogdDevProcess {
247    readiness: HogdDevReadiness,
248    child: SupervisedChild,
249    stderr_task: Option<JoinHandle<()>>,
250    shutdown_grace: Duration,
251    data_dir: Option<PathBuf>,
252    pid_file: Option<PathBuf>,
253    pid_guards: Vec<PidGuard>,
254    _bootstrap_manifest: Option<BootstrapManifestFile>,
255    local_state: Option<LocalChainState>,
256}
257
258impl HogdDevProcess {
259    /// Start hog-devnode using the configured binary lookup.
260    #[tracing::instrument(skip_all, fields(listen_host = %config.listen_host))]
261    pub async fn start(config: &HogdDevConfig) -> Result<Self, NodeSupervisorError> {
262        Self::start_with_inherited_file(config, None).await
263    }
264
265    pub(crate) async fn start_with_inherited_file(
266        config: &HogdDevConfig,
267        inherited_file: Option<std::fs::File>,
268    ) -> Result<Self, NodeSupervisorError> {
269        let binary = resolve_hogd_dev_binary(config)?;
270        Self::start_with_binary_and_file(config, binary, inherited_file).await
271    }
272
273    /// Start hog-devnode from an already-resolved binary path.
274    #[tracing::instrument(skip_all, fields(binary = %binary.as_ref().display()))]
275    pub async fn start_with_binary(
276        config: &HogdDevConfig,
277        binary: impl AsRef<Path>,
278    ) -> Result<Self, NodeSupervisorError> {
279        Self::start_with_binary_and_file(config, binary, None).await
280    }
281
282    async fn start_with_binary_and_file(
283        config: &HogdDevConfig,
284        binary: impl AsRef<Path>,
285        inherited_file: Option<std::fs::File>,
286    ) -> Result<Self, NodeSupervisorError> {
287        preflight_state_version(config)?;
288        prepare_persistent_data_dir(config)?;
289        let binary = binary.as_ref();
290        let pid_guards = acquire_pid_guards(config, binary.as_os_str()).await?;
291        // Preparing the chain does blocking file I/O and may wait out a
292        // contended chain lock, so it runs off the async thread.
293        let prepare_config = config.clone();
294        let state =
295            match tokio::task::spawn_blocking(move || LocalChainState::prepare(&prepare_config))
296                .await
297            {
298                Ok(state) => state?,
299                Err(error) if error.is_panic() => std::panic::resume_unwind(error.into_panic()),
300                Err(_) => {
301                    return Err(state_error(
302                        config
303                            .data_dir
304                            .as_deref()
305                            .unwrap_or(Path::new("ephemeral faucet")),
306                        "local chain preparation was cancelled",
307                    ));
308                }
309            };
310        let effective = state.config.clone();
311        let bootstrap_manifest =
312            write_bootstrap_manifest(&effective, &state.network_hash, state.public_key())?;
313        let spec = build_hogd_dev_spec_with_bootstrap_manifest(
314            binary,
315            &effective,
316            bootstrap_manifest.path(),
317        );
318        Self::start_with_spec(
319            &effective,
320            spec,
321            Some(bootstrap_manifest),
322            inherited_file,
323            Some(PreparedLaunch { state, pid_guards }),
324        )
325        .await
326    }
327
328    async fn start_with_spec(
329        config: &HogdDevConfig,
330        spec: ChildProcessSpec,
331        bootstrap_manifest: Option<BootstrapManifestFile>,
332        inherited_file: Option<std::fs::File>,
333        prepared: Option<PreparedLaunch>,
334    ) -> Result<Self, NodeSupervisorError> {
335        prepare_persistent_data_dir(config)?;
336        let (mut pid_guards, local_state) = match prepared {
337            Some(prepared) => (prepared.pid_guards, Some(prepared.state)),
338            None => (acquire_pid_guards(config, spec.program()).await?, None),
339        };
340        let chain_lock = local_state
341            .as_ref()
342            .and_then(|state| state.lock.as_ref())
343            .map(|file| file.try_clone())
344            .transpose()
345            .map_err(|source| NodeSupervisorError::DataDir {
346                path: config.data_dir.clone().unwrap_or_default(),
347                source,
348            })?;
349        let mut child = ChildSupervisor::new(spec).spawn_with_files(inherited_file, chain_lock)?;
350        let program = child.program().to_string();
351        if !pid_guards.is_empty() {
352            let pid = child.id().ok_or_else(|| NodeSupervisorError::Status {
353                program: program.clone(),
354                source: io::Error::new(
355                    io::ErrorKind::InvalidInput,
356                    "child process id is unavailable",
357                ),
358            })?;
359            for guard in &mut pid_guards {
360                if let Err(error) = guard.write(pid) {
361                    kill_unready_child(&mut child).await;
362                    return Err(error);
363                }
364            }
365        }
366
367        let stdout = child
368            .take_stdout()
369            .ok_or_else(|| NodeSupervisorError::MissingStdout {
370                program: program.clone(),
371            })?;
372        let stderr = child
373            .take_stderr()
374            .ok_or_else(|| NodeSupervisorError::MissingStderr {
375                program: program.clone(),
376            })?;
377        let mut stderr_task = Some(spawn_stderr_forwarder(stderr));
378
379        let readiness_result =
380            read_readiness_line(&mut child, stdout, config.startup_timeout).await;
381        let readiness = match readiness_result {
382            Ok(readiness) => readiness,
383            Err(error) => {
384                wait_for_stderr_task(stderr_task.take()).await;
385                return Err(error);
386            }
387        };
388
389        if let Some(state) = &local_state
390            && let Err(error) = validate_bootstrap_accounts(
391                &readiness,
392                config,
393                &state.network_hash,
394                state.public_key(),
395            )
396        {
397            kill_unready_child(&mut child).await;
398            wait_for_stderr_task(stderr_task.take()).await;
399            return Err(error);
400        }
401        Ok(Self {
402            readiness,
403            child,
404            stderr_task,
405            shutdown_grace: config.shutdown_grace,
406            data_dir: config.data_dir.clone(),
407            pid_file: config.pid_file.clone(),
408            pid_guards,
409            _bootstrap_manifest: bootstrap_manifest,
410            local_state,
411        })
412    }
413
414    /// Parsed readiness metadata.
415    pub fn readiness(&self) -> &HogdDevReadiness {
416        &self.readiness
417    }
418
419    /// Identity selected from initialization input or persisted state before spawning.
420    pub(crate) fn expected_network_hash(&self) -> &NetworkHashText {
421        &self
422            .local_state
423            .as_ref()
424            .expect("bug: supervised node must retain its prepared local chain state")
425            .network_hash
426    }
427
428    pub(crate) fn faucet_signer(&self) -> Result<std::sync::Arc<LocalSigner>, NodeSupervisorError> {
429        self.local_state
430            .as_ref()
431            .map(|state| state.signer.clone())
432            .ok_or_else(|| NodeSupervisorError::InvalidBootstrapManifestConfig {
433                reason: "local faucet credential was not prepared".into(),
434            })
435    }
436
437    pub(crate) fn funding_journal(
438        &self,
439    ) -> Result<
440        std::sync::Arc<std::sync::Mutex<crate::funding_journal::FundingJournal>>,
441        NodeSupervisorError,
442    > {
443        self.local_state
444            .as_ref()
445            .map(|state| state.journal.clone())
446            .ok_or_else(|| NodeSupervisorError::InvalidBootstrapManifestConfig {
447                reason: "local funding journal was not prepared".into(),
448            })
449    }
450
451    pub(crate) fn funding_lock_lease(&self) -> Option<std::sync::Arc<File>> {
452        self.local_state
453            .as_ref()
454            .and_then(|state| state.lock.clone())
455    }
456
457    /// Operating-system process id for hogd-dev, if available.
458    pub fn child_id(&self) -> Option<u32> {
459        self.child.id()
460    }
461
462    /// Wait for hogd-dev to exit.
463    #[tracing::instrument(skip_all)]
464    pub async fn stopped(&mut self) -> Result<ExitStatus, NodeSupervisorError> {
465        let mut status = Some(self.child.wait().await?);
466        #[cfg(unix)]
467        if self.child.process_group_exists()? {
468            tracing::warn!(
469                program = %self.child.program(),
470                process_group = ?self.child.process_group_id(),
471                persistent = self.data_dir.is_some(),
472                data_dir = ?self.data_dir,
473                pid_file = ?self.pid_file,
474                "hog-devnode leader exited while descendants remained; stopping its process group"
475            );
476            self.child.terminate_process_group()?;
477            self.drain_process_group(&mut status).await?;
478        }
479        self.wait_for_stderr_task().await;
480        self.release_pid_guard();
481        self.release_chain_lock();
482        Ok(status.expect("bug: stopped child has an exit status"))
483    }
484
485    /// Gracefully stop hogd-dev, escalating after the configured timeout.
486    #[tracing::instrument(skip_all, fields(shutdown_grace = ?self.shutdown_grace))]
487    pub async fn stop(&mut self) -> Result<ExitStatus, NodeSupervisorError> {
488        #[cfg(unix)]
489        let status = self.stop_process_group().await?;
490
491        #[cfg(not(unix))]
492        let status = match self.child.try_wait()? {
493            Some(status) => status,
494            None => self.child.kill_and_wait().await?,
495        };
496
497        self.wait_for_stderr_task().await;
498        self.release_pid_guard();
499        self.release_chain_lock();
500        Ok(status)
501    }
502
503    fn release_chain_lock(&mut self) {
504        if let Some(state) = &mut self.local_state {
505            state.lock.take();
506        }
507    }
508
509    #[cfg(unix)]
510    async fn stop_process_group(&mut self) -> Result<ExitStatus, NodeSupervisorError> {
511        let mut status = self.child.try_wait()?;
512        if self.child.process_group_exists()? {
513            self.child.terminate_process_group()?;
514            self.drain_process_group(&mut status).await?;
515        }
516
517        match status {
518            Some(status) => Ok(status),
519            None => time::timeout(Duration::from_millis(500), self.child.wait())
520                .await
521                .map_err(|_| self.shutdown_incomplete_error())?,
522        }
523    }
524
525    #[cfg(unix)]
526    async fn drain_process_group(
527        &mut self,
528        status: &mut Option<ExitStatus>,
529    ) -> Result<(), NodeSupervisorError> {
530        const POST_KILL_DRAIN: Duration = Duration::from_millis(500);
531
532        let deadline = time::Instant::now() + self.shutdown_grace;
533        let mut kill_deadline = None;
534
535        loop {
536            if status.is_none() {
537                *status = self.child.try_wait()?;
538            }
539
540            if !self.child.process_group_exists()? {
541                return Ok(());
542            }
543
544            let now = time::Instant::now();
545            if kill_deadline.is_none() && now >= deadline {
546                tracing::warn!(
547                    program = %self.child.program(),
548                    pid = ?self.child.id(),
549                    process_group = ?self.child.process_group_id(),
550                    shutdown_grace = ?self.shutdown_grace,
551                    persistent = self.data_dir.is_some(),
552                    data_dir = ?self.data_dir,
553                    pid_file = ?self.pid_file,
554                    "hog-devnode did not stop within its graceful shutdown deadline; sending SIGKILL; persistent state may require recovery"
555                );
556                self.child.kill_process_group()?;
557                kill_deadline = Some(now + POST_KILL_DRAIN);
558            }
559
560            if let Some(kill_deadline) = kill_deadline
561                && now >= kill_deadline
562            {
563                tracing::warn!(
564                    program = %self.child.program(),
565                    pid = ?self.child.id(),
566                    process_group = ?self.child.process_group_id(),
567                    persistent = self.data_dir.is_some(),
568                    data_dir = ?self.data_dir,
569                    pid_file = ?self.pid_file,
570                    "hog-devnode process group is still present after the SIGKILL drain"
571                );
572                return Err(self.shutdown_incomplete_error());
573            }
574
575            let next_deadline = kill_deadline.unwrap_or(deadline);
576            time::sleep(
577                next_deadline
578                    .saturating_duration_since(now)
579                    .min(Duration::from_millis(10)),
580            )
581            .await;
582        }
583    }
584
585    #[cfg(unix)]
586    fn shutdown_incomplete_error(&self) -> NodeSupervisorError {
587        NodeSupervisorError::ShutdownIncomplete {
588            program: self.child.program().to_string(),
589            process_group: self.child.process_group_id(),
590        }
591    }
592
593    async fn wait_for_stderr_task(&mut self) {
594        const STDERR_DRAIN: Duration = Duration::from_millis(500);
595
596        let Some(mut task) = self.stderr_task.take() else {
597            return;
598        };
599        if time::timeout(STDERR_DRAIN, &mut task).await.is_err() {
600            tracing::warn!(
601                program = %self.child.program(),
602                timeout = ?STDERR_DRAIN,
603                "hog-devnode stderr remained open after process-group shutdown; aborting log forwarding"
604            );
605            task.abort();
606            let _ = task.await;
607        }
608    }
609
610    fn release_pid_guard(&mut self) {
611        for guard in self.pid_guards.drain(..) {
612            guard.release();
613        }
614    }
615}
616
617struct PreparedLaunch {
618    state: LocalChainState,
619    pid_guards: Vec<PidGuard>,
620}
621
622async fn acquire_pid_guards(
623    config: &HogdDevConfig,
624    program: &std::ffi::OsStr,
625) -> Result<Vec<PidGuard>, NodeSupervisorError> {
626    let persistent_pid_file = config
627        .data_dir
628        .as_ref()
629        .map(|data_dir| data_dir.join(PERSISTENT_PID_FILE_NAME));
630    let mut pid_guards = Vec::new();
631    if let Some(pid_file) = &persistent_pid_file {
632        pid_guards.push(
633            PidGuard::acquire_persistent(pid_file.clone(), program, config.shutdown_grace).await?,
634        );
635    }
636    if let Some(pid_file) = &config.pid_file
637        && Some(pid_file) != persistent_pid_file.as_ref()
638    {
639        let guard = if config.data_dir.is_some() {
640            PidGuard::acquire_persistent(pid_file.clone(), program, config.shutdown_grace).await?
641        } else {
642            PidGuard::acquire(pid_file.clone(), program, config.shutdown_grace).await?
643        };
644        pid_guards.push(guard);
645    }
646
647    Ok(pid_guards)
648}
649
650#[cfg(unix)]
651fn prepare_persistent_data_dir(config: &HogdDevConfig) -> Result<(), NodeSupervisorError> {
652    use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
653
654    let Some(data_dir) = config.data_dir.as_deref() else {
655        return Ok(());
656    };
657    match fs::symlink_metadata(data_dir) {
658        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
659            Err(NodeSupervisorError::DataDir {
660                path: data_dir.to_path_buf(),
661                source: io::Error::new(
662                    io::ErrorKind::InvalidInput,
663                    "persistent data path must be a directory and must not be a symlink",
664                ),
665            })
666        }
667        Ok(_) => {
668            fs::set_permissions(data_dir, fs::Permissions::from_mode(0o700)).map_err(|source| {
669                NodeSupervisorError::DataDir {
670                    path: data_dir.to_path_buf(),
671                    source,
672                }
673            })
674        }
675        Err(source) if source.kind() == io::ErrorKind::NotFound => {
676            let mut builder = fs::DirBuilder::new();
677            builder.recursive(true).mode(0o700);
678            builder
679                .create(data_dir)
680                .map_err(|source| NodeSupervisorError::DataDir {
681                    path: data_dir.to_path_buf(),
682                    source,
683                })
684        }
685        Err(source) => Err(NodeSupervisorError::DataDir {
686            path: data_dir.to_path_buf(),
687            source,
688        }),
689    }
690}
691
692#[cfg(not(unix))]
693fn prepare_persistent_data_dir(config: &HogdDevConfig) -> Result<(), NodeSupervisorError> {
694    if config.data_dir.is_some() {
695        return Err(NodeSupervisorError::PersistentStateUnsupported);
696    }
697    Ok(())
698}
699
700#[derive(Debug)]
701struct BootstrapManifestFile {
702    _dir: TempDir,
703    path: PathBuf,
704}
705
706impl BootstrapManifestFile {
707    fn path(&self) -> &Path {
708        &self.path
709    }
710}
711
712fn write_bootstrap_manifest(
713    config: &HogdDevConfig,
714    network_hash: &NetworkHashText,
715    faucet_public_key: thruput_json_rpc_types::Ed25519PublicKey,
716) -> Result<BootstrapManifestFile, NodeSupervisorError> {
717    let manifest = config.to_bootstrap_manifest(network_hash.clone(), faucet_public_key);
718    let bytes = serde_json::to_vec_pretty(&manifest)
719        .map_err(|source| NodeSupervisorError::BootstrapManifestSerialize { source })?;
720    let dir = tempfile::Builder::new()
721        .prefix("thruput-hogd-bootstrap-")
722        .tempdir()
723        .map_err(|source| NodeSupervisorError::BootstrapManifestTempDir { source })?;
724    let path = dir.path().join(BOOTSTRAP_MANIFEST_FILE_NAME);
725    fs::write(&path, bytes).map_err(|source| NodeSupervisorError::BootstrapManifestWrite {
726        path: path.clone(),
727        source,
728    })?;
729    Ok(BootstrapManifestFile { _dir: dir, path })
730}
731
732/// Build the hogd-dev command specification used by the supervisor path.
733pub fn build_hogd_dev_spec_with_bootstrap_manifest(
734    binary: impl AsRef<Path>,
735    config: &HogdDevConfig,
736    bootstrap_manifest_path: impl AsRef<Path>,
737) -> ChildProcessSpec {
738    let mut spec = ChildProcessSpec::new(binary.as_ref().as_os_str().to_owned())
739        .args([
740            OsString::from("--listen-host"),
741            OsString::from(config.listen_host.as_str()),
742            OsString::from("--scheduler-port"),
743            OsString::from("0"),
744            OsString::from("--readiness"),
745            OsString::from("stdout"),
746            OsString::from("--bootstrap-manifest"),
747            bootstrap_manifest_path.as_ref().as_os_str().to_owned(),
748        ])
749        .env(HOGD_LOG_FORMAT_ENV, "json")
750        .kill_on_drop(config.data_dir.is_none())
751        .stdout_piped()
752        .stderr_piped();
753
754    spec = append_contract_artifact_directories(spec, &config.contract_artifact_directories);
755    spec = append_network_config_overrides(spec, &config.network_config_overrides);
756
757    if let Some(log_level) = config.log_level {
758        spec = spec.args([
759            OsString::from("--log-level"),
760            OsString::from(log_level.as_str()),
761        ]);
762    }
763
764    if let Some(data_dir) = &config.data_dir {
765        spec = spec.args([
766            OsString::from("--data-dir"),
767            data_dir.as_os_str().to_owned(),
768        ]);
769    }
770
771    if let Some(ramdb_bytes) = config.ramdb_bytes {
772        spec = spec.args([
773            OsString::from("--ramdb-bytes"),
774            OsString::from(ramdb_bytes.to_string()),
775        ]);
776    }
777
778    // Last, so hog-devnode's last-wins parsing lets these override the flags above.
779    spec = spec.args(config.extra_args.iter().cloned());
780
781    #[cfg(test)]
782    for (key, value) in &config.test_child_env {
783        spec = spec.env(key.clone(), value.clone());
784    }
785
786    #[cfg(unix)]
787    let spec = spec.process_group(0);
788
789    spec
790}
791
792fn append_contract_artifact_directories(
793    mut spec: ChildProcessSpec,
794    directories: &[PathBuf],
795) -> ChildProcessSpec {
796    for directory in directories {
797        spec = spec.args([
798            OsString::from("--contract-artifacts-dir"),
799            directory.as_os_str().to_owned(),
800        ]);
801    }
802    spec
803}
804
805fn append_network_config_overrides(
806    mut spec: ChildProcessSpec,
807    overrides: &BTreeMap<ConfigParamName, u64>,
808) -> ChildProcessSpec {
809    for (name, value) in overrides {
810        spec = spec.args([
811            OsString::from("--network-config"),
812            OsString::from(format!("{name}={value}")),
813        ]);
814    }
815    spec
816}
817
818/// Resolve the devnode binary: the configured path, then `THRUPUT_DEVNODE_BINARY`,
819/// then a binary beside the current executable, then `PATH`.
820pub fn resolve_hogd_dev_binary(config: &HogdDevConfig) -> Result<PathBuf, NodeSupervisorError> {
821    if let Some(binary) = resolve_hogd_dev_binary_before_path(config)? {
822        return Ok(binary);
823    }
824
825    resolve_hogd_dev_binary_on_path().ok_or(NodeSupervisorError::HogdDevNotFound)
826}
827
828/// Resolve configured, environment, or sibling binaries without consulting `PATH`.
829///
830/// Node-start orchestration uses this boundary to insert managed-toolchain acquisition
831/// ahead of the final `PATH` lookup. This function performs no downloads or filesystem
832/// mutations.
833pub fn resolve_hogd_dev_binary_before_path(
834    config: &HogdDevConfig,
835) -> Result<Option<PathBuf>, NodeSupervisorError> {
836    resolve_hogd_dev_binary_beside(config, env::current_exe)
837}
838
839/// [`resolve_hogd_dev_binary_before_path`], looking for a sibling binary next
840/// to the executable `current_exe` reports.
841fn resolve_hogd_dev_binary_beside(
842    config: &HogdDevConfig,
843    current_exe: impl FnOnce() -> std::io::Result<PathBuf>,
844) -> Result<Option<PathBuf>, NodeSupervisorError> {
845    if let Some(binary_path) = &config.binary_path {
846        return validate_configured_binary(binary_path).map(Some);
847    }
848
849    if let Some(env_path) = env::var_os(DEVNODE_BINARY_ENV) {
850        return validate_configured_binary(Path::new(&env_path)).map(Some);
851    }
852
853    let current_exe = current_exe().map_err(|source| NodeSupervisorError::CurrentExe { source })?;
854    if let Some(parent) = current_exe.parent() {
855        for binary in [DEFAULT_HOGD_DEV_BINARY, LEGACY_HOGD_DEV_BINARY] {
856            let sibling = parent.join(binary);
857            if is_executable_file(&sibling) {
858                return Ok(Some(sibling));
859            }
860        }
861    }
862
863    Ok(None)
864}
865
866/// Resolve the canonical or legacy devnode binary from `PATH` only.
867pub fn resolve_hogd_dev_binary_on_path() -> Option<PathBuf> {
868    if let Some(path) = env::var_os("PATH") {
869        for dir in env::split_paths(&path) {
870            for binary in [DEFAULT_HOGD_DEV_BINARY, LEGACY_HOGD_DEV_BINARY] {
871                let candidate = dir.join(binary);
872                if is_executable_file(&candidate) {
873                    return Some(candidate);
874                }
875            }
876        }
877    }
878
879    None
880}
881
882/// Parse one hog-devnode readiness line.
883pub fn parse_readiness_line(
884    program: &str,
885    line: &str,
886) -> Result<HogdDevReadiness, NodeSupervisorError> {
887    let value: serde_json::Value = parse_readiness_str(program, line)?;
888
889    if has_manifest_pointer_fields(&value) {
890        let envelope: HogdDevReadinessEnvelope =
891            parse_readiness_value(program, value.clone(), line)?;
892        if envelope.schema_version != 4 {
893            return Err(NodeSupervisorError::InvalidReadinessSchemaVersion {
894                program: program.to_string(),
895                expected: 4,
896                actual: envelope.schema_version,
897            });
898        }
899        let pointer: HogdDevReadinessPointer = parse_readiness_value(program, value, line)?;
900        return parse_manifest_readiness_pointer(program, pointer);
901    }
902
903    let readiness: HogdDevReadiness = parse_readiness_value(program, value, line)?;
904    readiness.ensure_ready(program)
905}
906
907fn parse_readiness_str<T>(program: &str, line: &str) -> Result<T, NodeSupervisorError>
908where
909    T: DeserializeOwned,
910{
911    serde_json::from_str(line).map_err(|source| NodeSupervisorError::InvalidReadinessJson {
912        program: program.to_string(),
913        line: line.to_string(),
914        source,
915    })
916}
917
918fn parse_readiness_value<T>(
919    program: &str,
920    value: serde_json::Value,
921    original_line: &str,
922) -> Result<T, NodeSupervisorError>
923where
924    T: DeserializeOwned,
925{
926    serde_json::from_value(value).map_err(|source| NodeSupervisorError::InvalidReadinessJson {
927        program: program.to_string(),
928        line: original_line.to_string(),
929        source,
930    })
931}
932
933fn has_manifest_pointer_fields(value: &serde_json::Value) -> bool {
934    let Some(object) = value.as_object() else {
935        return false;
936    };
937    object.contains_key("schemaVersion")
938        || object.contains_key("manifestPath")
939        || object.contains_key("manifestHash")
940}
941
942fn parse_manifest_readiness_pointer(
943    program: &str,
944    pointer: HogdDevReadinessPointer,
945) -> Result<HogdDevReadiness, NodeSupervisorError> {
946    debug_assert_eq!(pointer.schema_version, 4);
947    if pointer.status != "ready" {
948        return Err(NodeSupervisorError::InvalidReadinessStatus {
949            program: program.to_string(),
950            status: pointer.status,
951        });
952    }
953
954    validate_manifest_hash_format(program, &pointer.manifest_hash)?;
955    let manifest_path = pointer.manifest_path;
956    let advertised_manifest_hash = pointer.manifest_hash;
957    let manifest_bytes = read_runtime_manifest_bytes(program, &manifest_path)?;
958    let actual_hash = runtime_manifest_hash(&manifest_bytes);
959    if actual_hash != advertised_manifest_hash {
960        return Err(NodeSupervisorError::RuntimeManifestHashMismatch {
961            program: program.to_string(),
962            path: manifest_path.display().to_string(),
963            expected: advertised_manifest_hash,
964            actual: actual_hash,
965        });
966    }
967
968    let manifest = parse_runtime_manifest(&manifest_bytes).map_err(|source| {
969        NodeSupervisorError::InvalidRuntimeManifest {
970            program: program.to_string(),
971            reason: source.to_string(),
972        }
973    })?;
974    readiness_from_runtime_manifest(program, manifest, manifest_path, actual_hash)
975}
976
977fn read_runtime_manifest_bytes(
978    program: &str,
979    manifest_path: &Path,
980) -> Result<Vec<u8>, NodeSupervisorError> {
981    if !manifest_path.is_absolute() {
982        return Err(NodeSupervisorError::RuntimeManifestUnsafePermissions {
983            program: program.to_string(),
984            path: manifest_path.display().to_string(),
985            reason: "manifestPath must be absolute".to_string(),
986        });
987    }
988
989    validate_manifest_parent_dir(program, manifest_path)?;
990    let file = open_runtime_manifest_file(program, manifest_path)?;
991    read_capped_runtime_manifest(program, manifest_path, file)
992}
993
994fn read_capped_runtime_manifest(
995    program: &str,
996    manifest_path: &Path,
997    file: File,
998) -> Result<Vec<u8>, NodeSupervisorError> {
999    let mut bytes = Vec::new();
1000    let mut reader = file.take((MAX_RUNTIME_MANIFEST_BYTES + 1) as u64);
1001    reader
1002        .read_to_end(&mut bytes)
1003        .map_err(|source| NodeSupervisorError::RuntimeManifestRead {
1004            program: program.to_string(),
1005            path: manifest_path.display().to_string(),
1006            source,
1007        })?;
1008    if bytes.len() > MAX_RUNTIME_MANIFEST_BYTES {
1009        return Err(NodeSupervisorError::InvalidRuntimeManifest {
1010            program: program.to_string(),
1011            reason: format!("runtime manifest exceeds {MAX_RUNTIME_MANIFEST_BYTES} bytes"),
1012        });
1013    }
1014    Ok(bytes)
1015}
1016
1017#[cfg(unix)]
1018fn validate_manifest_parent_dir(
1019    program: &str,
1020    manifest_path: &Path,
1021) -> Result<(), NodeSupervisorError> {
1022    use std::os::unix::fs::MetadataExt;
1023
1024    let parent = manifest_path.parent().ok_or_else(|| {
1025        NodeSupervisorError::RuntimeManifestUnsafePermissions {
1026            program: program.to_string(),
1027            path: manifest_path.display().to_string(),
1028            reason: "manifestPath has no parent directory".to_string(),
1029        }
1030    })?;
1031    let metadata = std::fs::symlink_metadata(parent).map_err(|source| {
1032        NodeSupervisorError::RuntimeManifestRead {
1033            program: program.to_string(),
1034            path: parent.display().to_string(),
1035            source,
1036        }
1037    })?;
1038    if metadata.file_type().is_symlink() || !metadata.file_type().is_dir() {
1039        return Err(NodeSupervisorError::RuntimeManifestUnsafePermissions {
1040            program: program.to_string(),
1041            path: parent.display().to_string(),
1042            reason: "manifest parent must be a directory, not a symlink".to_string(),
1043        });
1044    }
1045    let expected_uid = unsafe { libc::geteuid() };
1046    if metadata.uid() != expected_uid {
1047        return Err(NodeSupervisorError::RuntimeManifestUnsafePermissions {
1048            program: program.to_string(),
1049            path: parent.display().to_string(),
1050            reason: format!(
1051                "manifest parent uid {} does not match current uid {expected_uid}",
1052                metadata.uid()
1053            ),
1054        });
1055    }
1056    let mode = metadata.mode() & 0o777;
1057    if mode & 0o077 != 0 {
1058        return Err(NodeSupervisorError::RuntimeManifestUnsafePermissions {
1059            program: program.to_string(),
1060            path: parent.display().to_string(),
1061            reason: format!("manifest parent mode {mode:03o} permits group or other access"),
1062        });
1063    }
1064    Ok(())
1065}
1066
1067#[cfg(not(unix))]
1068fn validate_manifest_parent_dir(
1069    _program: &str,
1070    _manifest_path: &Path,
1071) -> Result<(), NodeSupervisorError> {
1072    Ok(())
1073}
1074
1075#[cfg(unix)]
1076fn open_runtime_manifest_file(
1077    program: &str,
1078    manifest_path: &Path,
1079) -> Result<File, NodeSupervisorError> {
1080    use std::{
1081        fs::OpenOptions,
1082        mem::MaybeUninit,
1083        os::{fd::AsRawFd, unix::fs::OpenOptionsExt},
1084    };
1085
1086    let file = OpenOptions::new()
1087        .read(true)
1088        .custom_flags(libc::O_NOFOLLOW)
1089        .open(manifest_path)
1090        .map_err(|source| {
1091            if source.raw_os_error() == Some(libc::ELOOP) {
1092                return NodeSupervisorError::RuntimeManifestUnsafePermissions {
1093                    program: program.to_string(),
1094                    path: manifest_path.display().to_string(),
1095                    reason: "manifestPath must not be a symlink".to_string(),
1096                };
1097            }
1098            NodeSupervisorError::RuntimeManifestRead {
1099                program: program.to_string(),
1100                path: manifest_path.display().to_string(),
1101                source,
1102            }
1103        })?;
1104
1105    let mut stat = MaybeUninit::<libc::stat>::uninit();
1106    let rc = unsafe { libc::fstat(file.as_raw_fd(), stat.as_mut_ptr()) };
1107    if rc != 0 {
1108        return Err(NodeSupervisorError::RuntimeManifestRead {
1109            program: program.to_string(),
1110            path: manifest_path.display().to_string(),
1111            source: io::Error::last_os_error(),
1112        });
1113    }
1114    let stat = unsafe { stat.assume_init() };
1115    let mode = stat.st_mode & 0o777;
1116    let expected_uid = unsafe { libc::geteuid() };
1117    if stat.st_uid != expected_uid {
1118        return Err(NodeSupervisorError::RuntimeManifestUnsafePermissions {
1119            program: program.to_string(),
1120            path: manifest_path.display().to_string(),
1121            reason: format!(
1122                "manifest uid {} does not match current uid {expected_uid}",
1123                stat.st_uid
1124            ),
1125        });
1126    }
1127    if mode & 0o077 != 0 {
1128        return Err(NodeSupervisorError::RuntimeManifestUnsafePermissions {
1129            program: program.to_string(),
1130            path: manifest_path.display().to_string(),
1131            reason: format!("manifest mode {mode:03o} permits group or other access"),
1132        });
1133    }
1134
1135    let metadata = file
1136        .metadata()
1137        .map_err(|source| NodeSupervisorError::RuntimeManifestRead {
1138            program: program.to_string(),
1139            path: manifest_path.display().to_string(),
1140            source,
1141        })?;
1142    if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
1143        return Err(NodeSupervisorError::RuntimeManifestUnsafePermissions {
1144            program: program.to_string(),
1145            path: manifest_path.display().to_string(),
1146            reason: "manifest must be a regular file, not a symlink".to_string(),
1147        });
1148    }
1149
1150    Ok(file)
1151}
1152
1153#[cfg(not(unix))]
1154fn open_runtime_manifest_file(
1155    program: &str,
1156    manifest_path: &Path,
1157) -> Result<File, NodeSupervisorError> {
1158    File::open(manifest_path).map_err(|source| NodeSupervisorError::RuntimeManifestRead {
1159        program: program.to_string(),
1160        path: manifest_path.display().to_string(),
1161        source,
1162    })
1163}
1164
1165fn readiness_from_runtime_manifest(
1166    program: &str,
1167    manifest: RuntimeManifest,
1168    manifest_path: PathBuf,
1169    manifest_hash: String,
1170) -> Result<HogdDevReadiness, NodeSupervisorError> {
1171    if manifest.kind != HOGD_DEV_RUNTIME_MANIFEST_KIND {
1172        return Err(NodeSupervisorError::InvalidRuntimeManifest {
1173            program: program.to_string(),
1174            reason: format!(
1175                "unexpected runtime manifest kind `{}`; expected `{HOGD_DEV_RUNTIME_MANIFEST_KIND}`",
1176                manifest.kind
1177            ),
1178        });
1179    }
1180    let chain_state =
1181        manifest
1182            .chain_state
1183            .ok_or_else(|| NodeSupervisorError::InvalidRuntimeManifest {
1184                program: program.to_string(),
1185                reason: "hog-devnode runtime manifest is missing chainState".to_string(),
1186            })?;
1187    let scheduler_gateway = manifest.endpoints.scheduler_gateway.ok_or_else(|| {
1188        NodeSupervisorError::InvalidRuntimeManifest {
1189            program: program.to_string(),
1190            reason: "hog-devnode runtime manifest is missing endpoints.schedulerGateway"
1191                .to_string(),
1192        }
1193    })?;
1194    let dev_accounts = manifest
1195        .bootstrap_accounts
1196        .iter()
1197        .enumerate()
1198        .map(|(position, account)| {
1199            let initial_storage_tokens = account.initial_storage_tokens.parse::<u64>().map_err(
1200                |source| NodeSupervisorError::InvalidRuntimeManifest {
1201                    program: program.to_string(),
1202                    reason: format!(
1203                        "bootstrap account {position} initialStorageTokens is not a u64: {source}"
1204                    ),
1205                },
1206            )?;
1207            Ok(HogdDevReadyAccount {
1208                index: u32::try_from(position).map_err(|_| {
1209                    NodeSupervisorError::InvalidRuntimeManifest {
1210                        program: program.to_owned(),
1211                        reason: "too many bootstrap accounts".into(),
1212                    }
1213                })?,
1214                role: account.role.clone(),
1215                address: account.wallet_address,
1216                public_key: HexBytes(account.public_key.as_ref().to_vec()),
1217                initial_storage_tokens,
1218            })
1219        })
1220        .collect::<Result<Vec<_>, NodeSupervisorError>>()?;
1221
1222    Ok(HogdDevReadiness {
1223        status: "ready".to_string(),
1224        chain_state: Some(chain_state),
1225        network_hash: manifest.network_hash.clone(),
1226        protocol: Some(manifest.protocol.clone()),
1227        endpoints: HogdDevEndpoints {
1228            scheduler_coord: manifest.endpoints.scheduler,
1229            scheduler_gateway: Some(scheduler_gateway),
1230            indexer_block_explorer: manifest.endpoints.indexer,
1231            db_gateway: manifest.endpoints.db_gateway,
1232            view_gateway: manifest.endpoints.view_gateway,
1233        },
1234        dev_accounts,
1235        runtime_manifest: Some(manifest),
1236        manifest_path: Some(manifest_path),
1237        manifest_hash: Some(manifest_hash),
1238    })
1239}
1240
1241fn runtime_manifest_hash(bytes: &[u8]) -> String {
1242    thruput_json_rpc_types::SHA256Hash::from(thruput_xdr::types::SHA256Hash::from(
1243        <[u8; 32]>::from(Sha256::digest(bytes)),
1244    ))
1245    .to_string()
1246}
1247
1248fn validate_manifest_hash_format(program: &str, hash: &str) -> Result<(), NodeSupervisorError> {
1249    if thruput_xdr::types::SHA256Hash::decode_base58(hash).is_ok() {
1250        return Ok(());
1251    }
1252    Err(NodeSupervisorError::InvalidRuntimeManifestHash {
1253        program: program.to_string(),
1254        hash: hash.to_string(),
1255    })
1256}
1257
1258fn validate_configured_binary(path: &Path) -> Result<PathBuf, NodeSupervisorError> {
1259    if !path.exists() {
1260        return Err(NodeSupervisorError::ConfiguredPathMissing {
1261            path: path.to_path_buf(),
1262        });
1263    }
1264
1265    if !is_executable_file(path) {
1266        return Err(NodeSupervisorError::ConfiguredPathNotExecutable {
1267            path: path.to_path_buf(),
1268        });
1269    }
1270
1271    Ok(path.to_path_buf())
1272}
1273
1274#[cfg(unix)]
1275fn is_executable_file(path: &Path) -> bool {
1276    use std::os::unix::fs::PermissionsExt;
1277
1278    let Ok(metadata) = path.metadata() else {
1279        return false;
1280    };
1281    metadata.is_file() && metadata.permissions().mode() & 0o111 != 0
1282}
1283
1284#[cfg(not(unix))]
1285fn is_executable_file(path: &Path) -> bool {
1286    path.is_file()
1287}
1288
1289async fn read_readiness_line(
1290    child: &mut SupervisedChild,
1291    stdout: ChildStdout,
1292    timeout: Duration,
1293) -> Result<HogdDevReadiness, NodeSupervisorError> {
1294    let program = child.program().to_string();
1295    let mut stdout = BufReader::new(stdout);
1296    let read_result = time::timeout(
1297        timeout,
1298        read_capped_line(&mut stdout, MAX_READINESS_LINE_BYTES),
1299    )
1300    .await;
1301    match read_result {
1302        Ok(Ok(CappedLine::Eof)) => {
1303            // EOF on stdout means the child has either already exited or is
1304            // about to. `try_wait()` polls `waitpid`, which races SIGCHLD
1305            // delivery on Linux — so a tight EOF→try_wait sequence can see
1306            // `None` and misclassify a clean exit as `ReadinessRead`. Bridge
1307            // the race with a short `wait()` before giving up.
1308            if let Some(status) = wait_briefly_for_exit(child).await? {
1309                return Err(NodeSupervisorError::ExitedBeforeReady { program, status });
1310            }
1311            kill_unready_child(child).await;
1312            Err(NodeSupervisorError::ReadinessRead {
1313                program,
1314                source: io::Error::new(
1315                    io::ErrorKind::UnexpectedEof,
1316                    "child closed stdout before readiness",
1317                ),
1318            })
1319        }
1320        Ok(Ok(CappedLine::Line(line))) => {
1321            let line = line.trim_end_matches(['\r', '\n']);
1322            match parse_readiness_line(&program, line) {
1323                Ok(readiness) => Ok(readiness),
1324                Err(error) => {
1325                    kill_unready_child(child).await;
1326                    Err(error)
1327                }
1328            }
1329        }
1330        Ok(Ok(CappedLine::TooLarge)) => {
1331            kill_unready_child(child).await;
1332            Err(NodeSupervisorError::ReadinessTooLarge {
1333                program,
1334                max_bytes: MAX_READINESS_LINE_BYTES,
1335            })
1336        }
1337        Ok(Err(source)) => {
1338            kill_unready_child(child).await;
1339            Err(NodeSupervisorError::ReadinessRead { program, source })
1340        }
1341        Err(_) => {
1342            kill_unready_child(child).await;
1343            Err(NodeSupervisorError::ReadinessTimeout { program, timeout })
1344        }
1345    }
1346}
1347
1348/// Bridge the EOF→`try_wait` race after the child closes stdout.
1349///
1350/// Tries `try_wait()` first. If the child has not been reaped yet, awaits
1351/// `wait()` with a short cap — long enough to outlast any SIGCHLD delivery
1352/// jitter, short enough that a genuinely-stuck child still hits the cap and
1353/// gets force-killed by the caller.
1354async fn wait_briefly_for_exit(
1355    child: &mut SupervisedChild,
1356) -> Result<Option<ExitStatus>, NodeSupervisorError> {
1357    if let Some(status) = child.try_wait()? {
1358        return Ok(Some(status));
1359    }
1360    match time::timeout(Duration::from_millis(100), child.wait()).await {
1361        Ok(Ok(status)) => Ok(Some(status)),
1362        Ok(Err(error)) => Err(error),
1363        Err(_) => Ok(None),
1364    }
1365}
1366
1367enum CappedLine {
1368    Line(String),
1369    Eof,
1370    TooLarge,
1371}
1372
1373async fn read_capped_line<R>(reader: &mut R, max_bytes: usize) -> Result<CappedLine, io::Error>
1374where
1375    R: AsyncBufRead + Unpin,
1376{
1377    read_capped_line_inner(reader, max_bytes, false).await
1378}
1379
1380async fn read_capped_line_discarding_tail<R>(
1381    reader: &mut R,
1382    max_bytes: usize,
1383) -> Result<CappedLine, io::Error>
1384where
1385    R: AsyncBufRead + Unpin,
1386{
1387    read_capped_line_inner(reader, max_bytes, true).await
1388}
1389
1390async fn read_capped_line_inner<R>(
1391    reader: &mut R,
1392    max_bytes: usize,
1393    discard_oversized_tail: bool,
1394) -> Result<CappedLine, io::Error>
1395where
1396    R: AsyncBufRead + Unpin,
1397{
1398    let mut line = Vec::new();
1399
1400    loop {
1401        let buffer = reader.fill_buf().await?;
1402        if buffer.is_empty() {
1403            if line.is_empty() {
1404                return Ok(CappedLine::Eof);
1405            }
1406            return Ok(CappedLine::Line(
1407                String::from_utf8_lossy(&line).into_owned(),
1408            ));
1409        }
1410
1411        let newline = buffer
1412            .iter()
1413            .position(|byte| *byte == b'\n')
1414            .map(|position| position + 1);
1415        let line_end = newline.unwrap_or(buffer.len());
1416
1417        if line.len().saturating_add(line_end) > max_bytes {
1418            reader.consume(line_end);
1419            if discard_oversized_tail && newline.is_none() {
1420                discard_until_newline(reader).await?;
1421            }
1422            return Ok(CappedLine::TooLarge);
1423        }
1424
1425        line.extend_from_slice(&buffer[..line_end]);
1426        reader.consume(line_end);
1427        if newline.is_some() {
1428            return Ok(CappedLine::Line(
1429                String::from_utf8_lossy(&line).into_owned(),
1430            ));
1431        }
1432    }
1433}
1434
1435async fn discard_until_newline<R>(reader: &mut R) -> Result<(), io::Error>
1436where
1437    R: AsyncBufRead + Unpin,
1438{
1439    loop {
1440        let buffer = reader.fill_buf().await?;
1441        if buffer.is_empty() {
1442            return Ok(());
1443        }
1444
1445        let newline = buffer
1446            .iter()
1447            .position(|byte| *byte == b'\n')
1448            .map(|position| position + 1);
1449        let consume = newline.unwrap_or(buffer.len());
1450        reader.consume(consume);
1451        if newline.is_some() {
1452            return Ok(());
1453        }
1454    }
1455}
1456
1457const UNREADY_SHUTDOWN_GRACE: Duration = Duration::from_millis(500);
1458const UNREADY_KILL_DRAIN: Duration = Duration::from_millis(500);
1459
1460async fn kill_unready_child(child: &mut SupervisedChild) {
1461    #[cfg(unix)]
1462    kill_unready_process_group(child).await;
1463
1464    #[cfg(not(unix))]
1465    match time::timeout(UNREADY_KILL_DRAIN, child.kill_and_wait()).await {
1466        Ok(Ok(_)) => {}
1467        Ok(Err(error)) => tracing::warn!(
1468            target: "hogd_dev",
1469            %error,
1470            program = child.program(),
1471            "failed to kill unready hogd-dev child"
1472        ),
1473        Err(_) => tracing::warn!(
1474            target: "hogd_dev",
1475            program = child.program(),
1476            timeout = ?UNREADY_KILL_DRAIN,
1477            "timed out waiting for unready hog-devnode child after forced termination"
1478        ),
1479    }
1480}
1481
1482#[cfg(unix)]
1483async fn kill_unready_process_group(child: &mut SupervisedChild) {
1484    if let Err(error) = child.terminate_process_group() {
1485        tracing::warn!(
1486            target: "hogd_dev",
1487            %error,
1488            program = child.program(),
1489            "failed to SIGTERM unready hog-devnode process group"
1490        );
1491    }
1492    let term_deadline = time::Instant::now() + UNREADY_SHUTDOWN_GRACE;
1493    loop {
1494        if let Err(error) = child.try_wait() {
1495            tracing::warn!(
1496                target: "hogd_dev",
1497                %error,
1498                program = child.program(),
1499                "failed checking unready hog-devnode child status"
1500            );
1501            break;
1502        }
1503        match child.process_group_exists() {
1504            Ok(false) => {
1505                let _ = time::timeout(UNREADY_KILL_DRAIN, child.wait()).await;
1506                return;
1507            }
1508            Ok(true) if time::Instant::now() < term_deadline => {
1509                time::sleep(Duration::from_millis(10)).await;
1510            }
1511            Ok(true) => break,
1512            Err(error) => {
1513                tracing::warn!(
1514                    target: "hogd_dev",
1515                    %error,
1516                    program = child.program(),
1517                    "failed checking unready hog-devnode process group"
1518                );
1519                break;
1520            }
1521        }
1522    }
1523    tracing::warn!(
1524        target: "hogd_dev",
1525        program = child.program(),
1526        process_group = ?child.process_group_id(),
1527        shutdown_grace = ?UNREADY_SHUTDOWN_GRACE,
1528        "unready hog-devnode did not stop after SIGTERM; sending SIGKILL"
1529    );
1530    if let Err(error) = child.kill_process_group() {
1531        tracing::warn!(
1532            target: "hogd_dev",
1533            %error,
1534            program = child.program(),
1535            "failed to SIGKILL unready hog-devnode process group"
1536        );
1537    }
1538    match time::timeout(UNREADY_KILL_DRAIN, child.wait()).await {
1539        Ok(Ok(_)) => {}
1540        Ok(Err(error)) => tracing::warn!(
1541                target: "hogd_dev",
1542                %error,
1543                program = child.program(),
1544                "failed waiting for unready hogd-dev cleanup"
1545        ),
1546        Err(_) => tracing::warn!(
1547                target: "hogd_dev",
1548                program = child.program(),
1549                timeout = ?UNREADY_KILL_DRAIN,
1550                "timed out waiting for unready hog-devnode child after SIGKILL"
1551        ),
1552    }
1553    let deadline = time::Instant::now() + UNREADY_KILL_DRAIN;
1554    loop {
1555        match child.process_group_exists() {
1556            Ok(false) => break,
1557            Ok(true) if time::Instant::now() < deadline => {
1558                time::sleep(Duration::from_millis(10)).await;
1559            }
1560            Ok(true) => {
1561                tracing::warn!(
1562                    target: "hogd_dev",
1563                    program = child.program(),
1564                    process_group = ?child.process_group_id(),
1565                    "unready hog-devnode process group remained after SIGKILL drain"
1566                );
1567                break;
1568            }
1569            Err(error) => {
1570                tracing::warn!(
1571                    target: "hogd_dev",
1572                    %error,
1573                    program = child.program(),
1574                    "failed checking unready hog-devnode process group"
1575                );
1576                break;
1577            }
1578        }
1579    }
1580}
1581
1582fn spawn_stderr_forwarder(stderr: ChildStderr) -> JoinHandle<()> {
1583    tokio::spawn(async move {
1584        let mut stderr = BufReader::new(stderr);
1585
1586        loop {
1587            match read_capped_line_discarding_tail(&mut stderr, MAX_LOG_LINE_BYTES).await {
1588                Ok(CappedLine::Eof) => break,
1589                Ok(CappedLine::Line(line)) => {
1590                    forward_log_line(line.trim_end_matches(['\r', '\n']));
1591                }
1592                Ok(CappedLine::TooLarge) => {
1593                    tracing::warn!(
1594                        target: "hogd_dev",
1595                        max_bytes = MAX_LOG_LINE_BYTES,
1596                        "discarding oversized hogd-dev stderr log line"
1597                    );
1598                }
1599                Err(error) => {
1600                    tracing::warn!(target: "hogd_dev", %error, "failed to read hogd-dev stderr");
1601                    break;
1602                }
1603            }
1604        }
1605    })
1606}
1607
1608#[derive(Debug, Deserialize)]
1609struct HogdDevLogLine {
1610    #[serde(default)]
1611    timestamp_ms: Option<i64>,
1612    #[serde(default)]
1613    level: Option<String>,
1614    #[serde(default)]
1615    target: Option<String>,
1616    #[serde(default)]
1617    message: Option<String>,
1618    #[serde(default)]
1619    fields: Option<serde_json::Value>,
1620}
1621
1622#[derive(Debug, Clone, PartialEq, Eq)]
1623enum ForwardedLogLevel {
1624    Error,
1625    Warn,
1626    Info,
1627    Debug,
1628    Trace,
1629    Unknown(String),
1630    Missing,
1631}
1632
1633impl ForwardedLogLevel {
1634    fn from_record(level: Option<&str>) -> Self {
1635        let Some(level) = level else {
1636            return Self::Missing;
1637        };
1638        if level.eq_ignore_ascii_case("error") {
1639            Self::Error
1640        } else if level.eq_ignore_ascii_case("warn") || level.eq_ignore_ascii_case("warning") {
1641            Self::Warn
1642        } else if level.eq_ignore_ascii_case("info") {
1643            Self::Info
1644        } else if level.eq_ignore_ascii_case("debug") {
1645            Self::Debug
1646        } else if level.eq_ignore_ascii_case("trace") {
1647            Self::Trace
1648        } else {
1649            Self::Unknown(level.to_owned())
1650        }
1651    }
1652
1653    fn as_log_field(&self) -> Option<&str> {
1654        match self {
1655            Self::Unknown(level) => Some(level.as_str()),
1656            Self::Missing => Some("<missing>"),
1657            _ => None,
1658        }
1659    }
1660}
1661
1662#[derive(Debug, Clone, PartialEq, Eq)]
1663struct ForwardedLogRecord {
1664    timestamp_ms: Option<i64>,
1665    level: ForwardedLogLevel,
1666    hogd_target: String,
1667    message: String,
1668    fields: ForwardedLogFields,
1669}
1670
1671#[derive(Debug, Clone, PartialEq, Eq)]
1672enum ForwardLogOutcome {
1673    Forwarded(ForwardedLogRecord),
1674    InvalidJson,
1675}
1676
1677fn forward_log_line(line: &str) -> ForwardLogOutcome {
1678    match serde_json::from_str::<HogdDevLogLine>(line) {
1679        Ok(record) => {
1680            let forwarded = ForwardedLogRecord {
1681                timestamp_ms: record.timestamp_ms,
1682                level: ForwardedLogLevel::from_record(record.level.as_deref()),
1683                hogd_target: record.target.unwrap_or_else(|| "hogd_dev".to_owned()),
1684                message: record.message.unwrap_or_default(),
1685                fields: parse_log_fields(record.fields.as_ref()),
1686            };
1687            emit_forwarded_log(&forwarded);
1688            ForwardLogOutcome::Forwarded(forwarded)
1689        }
1690        Err(error) => {
1691            tracing::warn!(
1692                target: "hogd_dev",
1693                %error,
1694                line,
1695                "invalid hogd-dev stderr log line"
1696            );
1697            ForwardLogOutcome::InvalidJson
1698        }
1699    }
1700}
1701
1702macro_rules! emit_hogd_log {
1703    ($level:ident, $record:expr, $hogd_file:expr, $hogd_line:expr, $hogd_fields:expr) => {
1704        tracing::$level!(
1705            target: "hogd_dev",
1706            hogd_timestamp_ms = $record.timestamp_ms,
1707            hogd_target = %$record.hogd_target,
1708            hogd_file = %$hogd_file,
1709            hogd_line = $hogd_line,
1710            hogd_fields = %$hogd_fields,
1711            "{}",
1712            $record.message
1713        );
1714    };
1715}
1716
1717fn emit_forwarded_log(record: &ForwardedLogRecord) {
1718    let hogd_file = record.fields.file.as_deref().unwrap_or("");
1719    let hogd_line = record.fields.line.unwrap_or(0);
1720    let hogd_fields = record.fields.raw.as_str();
1721
1722    match &record.level {
1723        ForwardedLogLevel::Error => {
1724            emit_hogd_log!(error, record, hogd_file, hogd_line, hogd_fields);
1725        }
1726        ForwardedLogLevel::Warn => {
1727            emit_hogd_log!(warn, record, hogd_file, hogd_line, hogd_fields);
1728        }
1729        ForwardedLogLevel::Info => {
1730            emit_hogd_log!(info, record, hogd_file, hogd_line, hogd_fields);
1731        }
1732        ForwardedLogLevel::Debug => {
1733            emit_hogd_log!(debug, record, hogd_file, hogd_line, hogd_fields);
1734        }
1735        ForwardedLogLevel::Trace => {
1736            emit_hogd_log!(trace, record, hogd_file, hogd_line, hogd_fields);
1737        }
1738        ForwardedLogLevel::Unknown(_) | ForwardedLogLevel::Missing => {
1739            let level = record.level.as_log_field().unwrap_or("<missing>");
1740            tracing::warn!(
1741                target: "hogd_dev",
1742                hogd_timestamp_ms = record.timestamp_ms,
1743                hogd_target = %record.hogd_target,
1744                %hogd_file,
1745                hogd_line,
1746                %hogd_fields,
1747                %level,
1748                "{}",
1749                record.message
1750            );
1751        }
1752    }
1753}
1754
1755#[derive(Debug, Clone, Default, PartialEq, Eq)]
1756struct ForwardedLogFields {
1757    raw: String,
1758    file: Option<String>,
1759    line: Option<u64>,
1760}
1761
1762fn parse_log_fields(fields: Option<&serde_json::Value>) -> ForwardedLogFields {
1763    let Some(fields) = fields else {
1764        return ForwardedLogFields::default();
1765    };
1766
1767    ForwardedLogFields {
1768        raw: fields.to_string(),
1769        file: fields
1770            .get("file")
1771            .and_then(serde_json::Value::as_str)
1772            .map(str::to_owned),
1773        line: fields.get("line").and_then(serde_json::Value::as_u64),
1774    }
1775}
1776
1777async fn wait_for_stderr_task(task: Option<JoinHandle<()>>) {
1778    const STDERR_DRAIN: Duration = Duration::from_millis(500);
1779
1780    let Some(mut task) = task else {
1781        return;
1782    };
1783    if time::timeout(STDERR_DRAIN, &mut task).await.is_err() {
1784        tracing::warn!(
1785            timeout = ?STDERR_DRAIN,
1786            "hog-devnode stderr remained open after failed startup; aborting log forwarding"
1787        );
1788        task.abort();
1789        let _ = task.await;
1790    }
1791}
1792
1793#[cfg(test)]
1794#[cfg(unix)]
1795mod tests;