1use 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
43pub const DEFAULT_HOGD_DEV_BINARY: &str = "hog-devnode";
45
46pub const LEGACY_HOGD_DEV_BINARY: &str = "hogd-dev";
48
49pub const DEVNODE_BINARY_ENV: &str = "THRUPUT_DEVNODE_BINARY";
51
52pub const HOGD_LOG_FORMAT_ENV: &str = "HOGD_LOG_FORMAT";
54
55pub const MAX_READINESS_LINE_BYTES: usize = 64 * 1024;
57
58pub const MAX_LOG_LINE_BYTES: usize = 64 * 1024;
60
61pub const MAX_RUNTIME_MANIFEST_BYTES: usize = 1024 * 1024;
63
64pub 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#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
72#[non_exhaustive]
73pub struct HogdDevEndpoints {
74 #[serde(alias = "scheduler")]
79 pub scheduler_coord: SocketAddr,
80 #[serde(default)]
83 pub scheduler_gateway: Option<SocketAddr>,
84 #[serde(default, alias = "read")]
87 pub indexer_block_explorer: Option<SocketAddr>,
88 #[serde(default, alias = "gateway")]
91 pub db_gateway: Option<SocketAddr>,
92 #[serde(default, alias = "view")]
95 pub view_gateway: Option<SocketAddr>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
100#[non_exhaustive]
101pub(crate) struct HogdDevReadyAccount {
102 pub index: u32,
104 pub role: String,
106 pub address: ContractAddress,
108 pub public_key: HexBytes,
110 pub initial_storage_tokens: u64,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
116#[non_exhaustive]
117pub struct HogdDevReadiness {
118 pub status: String,
120 #[serde(default)]
122 pub chain_state: Option<RuntimeManifestChainState>,
123 pub network_hash: NetworkHashText,
125 #[serde(default)]
127 pub protocol: Option<String>,
128 pub endpoints: HogdDevEndpoints,
130 #[serde(default)]
132 pub(crate) dev_accounts: Vec<HogdDevReadyAccount>,
133 #[serde(default)]
135 pub runtime_manifest: Option<RuntimeManifest>,
136 #[serde(default)]
138 pub manifest_path: Option<PathBuf>,
139 #[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
171pub(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#[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 #[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 #[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 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 pub fn readiness(&self) -> &HogdDevReadiness {
416 &self.readiness
417 }
418
419 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 pub fn child_id(&self) -> Option<u32> {
459 self.child.id()
460 }
461
462 #[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 #[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
732pub 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 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
818pub 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
828pub 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
839fn 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
866pub 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
882pub 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 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
1348async 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;