1use std::{future::Future, io, net::SocketAddr, process::ExitStatus};
11
12use jsonrpsee::server::{Server, ServerConfig, ServerHandle};
13use serde::Serialize;
14use thruput_bundle_identity::BundleIdentity;
15use thruput_json_rpc_server::batch::{ConcurrentBatchService, MAX_RPC_BATCH_REQUESTS};
16use thruput_json_rpc_server::{
17 RpcServerConfig,
18 native::{
19 read::{NativeBlockPlane, NativeHistoryPlane, NativeSimulationPlane, NativeStatePlane},
20 service::{NativeCapabilities, NativeService},
21 transaction::NativeTransactionReadPlane,
22 },
23};
24use thruput_json_rpc_types::protocol_token::protocol_text;
25use thruput_json_rpc_types::{NetworkHash, network_info::NetworkInfo};
26use thruput_xdr::PROTOCOL_VERSION;
27
28use crate::{
29 BlockDriverConfig, HogdDevProcess, HogdDevReadiness, NodeSupervisorConfig, NodeSupervisorError,
30 airdrop::{LocalAirdropContext, LocalAirdropRpc},
31 block_driver::{BlockDriverTask, ManualBlockProducer},
32 hogd_dev::{DEFAULT_HOGD_DEV_BINARY, validate_runtime_identity},
33 read_backend::{HogdDevReadBackend, HogdDevStorageReadBackend},
34 rpc_backend::HogdDevSchedulerBackend,
35 stub_backend::StubBackend,
36 view_backend::HogdDevViewBackend,
37};
38
39#[derive(Debug)]
46#[non_exhaustive]
47pub enum SupervisorStop {
48 HogdExited(ExitStatus),
51 RpcStopped(ExitStatus),
56 StopRequested(ExitStatus),
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
64#[serde(rename_all = "snake_case")]
65#[non_exhaustive]
66pub enum PlaneStatus {
67 Scheduler,
70 Indexer,
72 DbGateway,
74 ViewGateway,
76 Stub,
78}
79
80impl PlaneStatus {
81 pub const fn is_indexer_backed(self) -> bool {
83 match self {
84 Self::Indexer => true,
85 Self::Scheduler | Self::DbGateway | Self::ViewGateway | Self::Stub => false,
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
92pub struct BackendPlanes {
93 pub submit: PlaneStatus,
95 pub chain: PlaneStatus,
97 pub read: PlaneStatus,
99 pub view: PlaneStatus,
101}
102
103impl BackendPlanes {
104 pub const fn hogd_dev_scheduler_only() -> Self {
106 Self {
107 submit: PlaneStatus::Scheduler,
108 chain: PlaneStatus::Stub,
109 read: PlaneStatus::Stub,
110 view: PlaneStatus::Stub,
111 }
112 }
113
114 pub const fn hogd_dev_full(view: PlaneStatus) -> Self {
116 Self {
117 submit: PlaneStatus::Scheduler,
118 chain: PlaneStatus::DbGateway,
119 read: PlaneStatus::Indexer,
120 view,
121 }
122 }
123
124 pub const fn hogd_dev_storage_only(view: PlaneStatus) -> Self {
126 Self {
127 submit: PlaneStatus::Scheduler,
128 chain: PlaneStatus::DbGateway,
129 read: PlaneStatus::DbGateway,
130 view,
131 }
132 }
133}
134
135#[derive(Debug)]
144pub struct NodeSupervisorHandle {
145 hogd: HogdDevProcess,
146 rpc_handle: ServerHandle,
147 rpc_addr: SocketAddr,
148 backend_planes: BackendPlanes,
149 manual_block_producer: Option<ManualBlockProducer>,
150 block_driver: Option<BlockDriverTask>,
151}
152
153impl NodeSupervisorHandle {
154 #[tracing::instrument(skip_all, fields(rpc_listen = %config.rpc_listen_address))]
161 pub async fn start(config: NodeSupervisorConfig) -> Result<Self, NodeSupervisorError> {
162 Self::start_with_inherited_file(config, None).await
163 }
164
165 pub async fn start_with_inherited_file(
170 config: NodeSupervisorConfig,
171 inherited_file: Option<std::fs::File>,
172 ) -> Result<Self, NodeSupervisorError> {
173 if let Some(identity) = &config.bundle_identity {
174 tracing::info!(
175 component_version = identity.build_version().crate_version(),
176 source_commit = ?identity.build_version().source_commit(),
177 release_version = ?identity.release_version(),
178 bundle_manifest_digest = ?identity.bundle_manifest_digest(),
179 bundle_manifest_mismatch_reason = ?identity.mismatch_reason(),
180 "Thruput release identity",
181 );
182 }
183 config
184 .local_airdrop
185 .validate()
186 .map_err(|reason| NodeSupervisorError::InvalidLocalAirdropConfig { reason })?;
187 let mut hogd =
188 HogdDevProcess::start_with_inherited_file(&config.hogd_dev, inherited_file).await?;
189
190 let default_protocol = protocol_text(&PROTOCOL_VERSION);
193 let protocol_version = config
194 .hogd_protocol_override
195 .as_deref()
196 .unwrap_or(&default_protocol);
197
198 match validate_runtime_identity(
199 hogd.readiness(),
200 protocol_version,
201 hogd.expected_network_hash(),
202 ) {
203 Ok(()) => {}
204 Err(error) => {
205 if let Err(stop_err) = hogd.stop().await {
206 tracing::warn!(
207 error = %stop_err,
208 "failed to stop hogd-dev after readiness validation error",
209 );
210 }
211 return Err(error);
212 }
213 }
214
215 let started = async {
216 start_rpc_server(
217 &config,
218 hogd.readiness(),
219 protocol_version,
220 hogd.faucet_signer()?,
221 hogd.funding_journal()?,
222 hogd.funding_lock_lease(),
223 )
224 .await
225 }
226 .await;
227 match started {
228 Ok(started) => {
229 tracing::info!(rpc_addr = %started.addr, "node supervisor JSON-RPC ready");
230 Ok(Self {
231 hogd,
232 rpc_handle: started.handle,
233 rpc_addr: started.addr,
234 backend_planes: started.backend_planes,
235 manual_block_producer: started.block_driver.manual_producer(),
236 block_driver: Some(started.block_driver),
237 })
238 }
239 Err(error) => {
240 if let Err(stop_err) = hogd.stop().await {
243 tracing::warn!(
244 error = %stop_err,
245 "failed to stop hogd-dev after JSON-RPC startup error",
246 );
247 }
248 Err(error)
249 }
250 }
251 }
252
253 pub fn rpc_addr(&self) -> SocketAddr {
256 self.rpc_addr
257 }
258
259 pub fn hogd_readiness(&self) -> &HogdDevReadiness {
261 self.hogd.readiness()
262 }
263
264 pub fn backend_planes(&self) -> BackendPlanes {
266 self.backend_planes
267 }
268
269 pub fn hogd_child_id(&self) -> Option<u32> {
271 self.hogd.child_id()
272 }
273
274 pub fn manual_block_producer(&self) -> Option<ManualBlockProducer> {
276 self.manual_block_producer.clone()
277 }
278
279 #[tracing::instrument(skip_all)]
291 pub async fn stopped(mut self) -> Result<SupervisorStop, NodeSupervisorError> {
292 self.run_until_inner(std::future::pending::<Result<(), NodeSupervisorError>>())
293 .await
294 }
295
296 #[tracing::instrument(skip_all)]
303 pub async fn run_until<F>(
304 mut self,
305 stop_signal: F,
306 ) -> Result<SupervisorStop, NodeSupervisorError>
307 where
308 F: Future<Output = Result<(), NodeSupervisorError>>,
309 {
310 self.run_until_inner(stop_signal).await
311 }
312
313 async fn run_until_inner<F>(
314 &mut self,
315 stop_signal: F,
316 ) -> Result<SupervisorStop, NodeSupervisorError>
317 where
318 F: Future<Output = Result<(), NodeSupervisorError>>,
319 {
320 let rpc_done = self.rpc_handle.clone().stopped();
321 tokio::select! {
322 result = self.hogd.stopped() => {
323 match &result {
324 Ok(status) => tracing::warn!(?status, "hogd-dev exited; stopping JSON-RPC server"),
325 Err(error) => tracing::error!(%error, "hog-devnode cleanup failed; stopping JSON-RPC server"),
326 }
327 if let Err(error) = self.rpc_handle.stop() {
328 tracing::debug!(%error, "JSON-RPC server already stopped");
329 }
330 self.stop_block_driver().await;
331 self.rpc_handle.clone().stopped().await;
332 result.map(SupervisorStop::HogdExited)
333 }
334 () = rpc_done => {
335 tracing::info!("JSON-RPC server stopped; stopping hogd-dev");
336 let status = self.stop_inner().await?;
337 Ok(SupervisorStop::RpcStopped(status))
338 }
339 result = stop_signal => {
340 if let Err(error) = result {
341 tracing::warn!(%error, "stop signal failed; stopping node supervisor");
342 if let Err(stop_error) = self.stop_inner().await {
343 tracing::error!(%stop_error, "failed to stop node supervisor after stop signal error");
344 }
345 return Err(error);
346 }
347 tracing::info!("stop signal received; stopping node supervisor");
348 let status = self.stop_inner().await?;
349 Ok(SupervisorStop::StopRequested(status))
350 }
351 }
352 }
353
354 #[tracing::instrument(skip_all)]
357 pub async fn stop(mut self) -> Result<ExitStatus, NodeSupervisorError> {
358 self.stop_inner().await
359 }
360
361 async fn stop_inner(&mut self) -> Result<ExitStatus, NodeSupervisorError> {
362 if let Err(error) = self.rpc_handle.stop() {
363 tracing::debug!(%error, "JSON-RPC server already stopped");
364 }
365 self.stop_block_driver().await;
366 self.rpc_handle.clone().stopped().await;
367 self.hogd.stop().await
368 }
369
370 async fn stop_block_driver(&mut self) {
371 if let Some(block_driver) = self.block_driver.take() {
372 block_driver.stop().await;
373 }
374 }
375}
376
377async fn start_rpc_server(
378 config: &NodeSupervisorConfig,
379 readiness: &HogdDevReadiness,
380 protocol_version: &str,
381 signer: std::sync::Arc<thruput_signing::LocalSigner>,
382 journal: std::sync::Arc<std::sync::Mutex<crate::funding_journal::FundingJournal>>,
383 lock_lease: Option<std::sync::Arc<std::fs::File>>,
384) -> Result<StartedRpcServer, NodeSupervisorError> {
385 let listen_address = &config.rpc_listen_address;
386 let manifest = readiness.runtime_manifest.as_ref().ok_or_else(|| {
387 NodeSupervisorError::InvalidRuntimeManifest {
388 program: DEFAULT_HOGD_DEV_BINARY.to_owned(),
389 reason: "hog-devnode readiness must advertise a runtime manifest".to_owned(),
390 }
391 })?;
392 let network_hash = manifest.network_hash.to_network_hash().into_inner();
393 let network_info = NetworkInfo::new(NetworkHash::from(network_hash));
394 let scheduler_backend = connect_scheduler_backend(readiness, protocol_version).await?;
395
396 let view = HogdDevViewBackend::connect_optional(
397 readiness.endpoints.view_gateway,
398 protocol_version,
399 readiness.protocol.as_deref(),
400 config.view_timeout,
401 )
402 .await?;
403 let view_status = view.plane_status();
404 let db_gateway = readiness.endpoints.db_gateway;
405
406 if let Some(indexer_addr) = readiness.endpoints.indexer_block_explorer {
409 let local_airdrop =
410 validated_local_airdrop_context(manifest, config, signer, journal, lock_lease)?;
411 tracing::debug!(%indexer_addr, "connecting to hogd-dev IndexerBlockExplorer");
412 let read = HogdDevReadBackend::connect(
413 indexer_addr,
414 db_gateway,
415 scheduler_backend.scheduler_gateway_client().clone(),
416 protocol_version,
417 readiness.protocol.as_deref(),
418 )
419 .await?;
420 start_rpc_server_with_read(
421 listen_address,
422 scheduler_backend.with_db_gateway(
423 read.db_gateway_client().cloned(),
424 readiness.network_hash.clone(),
425 ),
426 read,
427 view,
428 rpc_server_options(
429 config,
430 network_info,
431 Some(local_airdrop),
432 BackendPlanes {
433 chain: db_gateway.map_or(PlaneStatus::Stub, |_| PlaneStatus::DbGateway),
434 ..BackendPlanes::hogd_dev_full(view_status)
435 },
436 ),
437 )
438 .await
439 } else if let Some(db_gateway_addr) = db_gateway {
440 let read = HogdDevStorageReadBackend::connect(
441 db_gateway_addr,
442 protocol_version,
443 readiness.protocol.as_deref(),
444 )
445 .await?;
446 start_rpc_server_with_read(
447 listen_address,
448 scheduler_backend.with_db_gateway(
449 Some(read.db_gateway_client().clone()),
450 readiness.network_hash.clone(),
451 ),
452 read,
453 view,
454 rpc_server_options(
455 config,
456 network_info,
457 None,
458 BackendPlanes::hogd_dev_storage_only(view_status),
459 ),
460 )
461 .await
462 } else {
463 tracing::warn!(
464 "hogd-dev did not advertise indexer or database endpoints; dependent RPC methods are unavailable"
465 );
466 start_rpc_server_with_read(
467 listen_address,
468 scheduler_backend,
469 StubBackend::new(),
470 view,
471 rpc_server_options(
472 config,
473 network_info,
474 None,
475 BackendPlanes {
476 view: view_status,
477 ..BackendPlanes::hogd_dev_scheduler_only()
478 },
479 ),
480 )
481 .await
482 }
483}
484
485async fn connect_scheduler_backend(
486 readiness: &HogdDevReadiness,
487 protocol_version: &str,
488) -> Result<HogdDevSchedulerBackend, NodeSupervisorError> {
489 let scheduler_gateway = readiness.endpoints.scheduler_gateway.ok_or_else(|| {
490 NodeSupervisorError::InvalidRuntimeManifest {
491 program: DEFAULT_HOGD_DEV_BINARY.to_owned(),
492 reason: "hog-devnode runtime manifest is missing endpoints.schedulerGateway".to_owned(),
493 }
494 })?;
495 let scheduler_backend = HogdDevSchedulerBackend::connect(
496 readiness.endpoints.scheduler_coord,
497 scheduler_gateway,
498 protocol_version,
499 readiness.protocol.as_deref(),
500 )
501 .await?;
502 scheduler_backend
503 .require_dev_genesis(&readiness.network_hash)
504 .await?;
505 Ok(scheduler_backend)
506}
507
508fn rpc_server_options(
509 config: &NodeSupervisorConfig,
510 network_info: NetworkInfo,
511 local_airdrop: Option<LocalAirdropContext>,
512 backend_planes: BackendPlanes,
513) -> RpcServerOptions {
514 RpcServerOptions {
515 network_info,
516 local_airdrop,
517 backend_planes,
518 view_gas_limit: config.view_gas_limit,
519 view_batch: config.view_batch,
520 view_timeout_ms: u64::try_from(config.view_timeout.as_millis()).unwrap_or(u64::MAX),
521 block_driver_config: config.block_driver,
522 rpc_max_connections: config.rpc_max_connections,
523 rpc_batch_concurrency: config.rpc_batch_concurrency,
524 bundle_identity: config.bundle_identity.clone(),
525 }
526}
527
528fn validated_local_airdrop_context(
529 manifest: &thruput_json_rpc_types::runtime_manifest::RuntimeManifest,
530 config: &NodeSupervisorConfig,
531 signer: std::sync::Arc<thruput_signing::LocalSigner>,
532 journal: std::sync::Arc<std::sync::Mutex<crate::funding_journal::FundingJournal>>,
533 lock_lease: Option<std::sync::Arc<std::fs::File>>,
534) -> Result<LocalAirdropContext, NodeSupervisorError> {
535 LocalAirdropContext::from_validated_runtime(manifest, config, signer, journal, lock_lease)
536 .map_err(|reason| NodeSupervisorError::InvalidRuntimeManifest {
537 program: DEFAULT_HOGD_DEV_BINARY.to_owned(),
538 reason,
539 })
540}
541
542struct RpcServerOptions {
543 view_batch: thruput_json_rpc_server::ViewBatchConfig,
544 view_timeout_ms: u64,
545 network_info: NetworkInfo,
546 local_airdrop: Option<LocalAirdropContext>,
547 backend_planes: BackendPlanes,
548 view_gas_limit: u64,
549 block_driver_config: BlockDriverConfig,
550 rpc_max_connections: u32,
551 rpc_batch_concurrency: thruput_json_rpc_server::batch::RpcBatchConcurrency,
552 bundle_identity: Option<BundleIdentity>,
553}
554
555async fn start_rpc_server_with_read<R, V>(
556 listen_address: &str,
557 scheduler_backend: HogdDevSchedulerBackend,
558 read: R,
559 view: V,
560 options: RpcServerOptions,
561) -> Result<StartedRpcServer, NodeSupervisorError>
562where
563 R: crate::airdrop::FundingStatusPlane
564 + NativeStatePlane
565 + NativeTransactionReadPlane
566 + NativeBlockPlane
567 + NativeHistoryPlane
568 + Clone,
569 V: NativeSimulationPlane,
570{
571 let RpcServerOptions {
572 view_batch,
573 view_timeout_ms,
574 network_info,
575 local_airdrop,
576 backend_planes,
577 view_gas_limit,
578 block_driver_config,
579 rpc_max_connections,
580 rpc_batch_concurrency,
581 bundle_identity,
582 } = options;
583 ensure_rpc_nofile_limit(rpc_max_connections)?;
584 let max_response_body_size = jsonrpsee::core::TEN_MB_SIZE_BYTES;
585 let server_config = ServerConfig::builder()
586 .max_connections(rpc_max_connections)
587 .max_response_body_size(max_response_body_size)
588 .set_batch_request_config(jsonrpsee::server::BatchRequestConfig::Limit(
589 MAX_RPC_BATCH_REQUESTS,
590 ))
591 .build();
592 let rpc_middleware =
593 jsonrpsee::core::middleware::RpcServiceBuilder::new().layer_fn(move |inner| {
594 ConcurrentBatchService::new(inner, max_response_body_size, rpc_batch_concurrency)
595 });
596 let server = Server::builder()
597 .set_config(server_config)
598 .set_rpc_middleware(rpc_middleware)
599 .build(listen_address)
600 .await
601 .map_err(|source| NodeSupervisorError::RpcBind {
602 address: listen_address.to_string(),
603 source,
604 })?;
605 let rpc_addr = server
606 .local_addr()
607 .map_err(|source| NodeSupervisorError::RpcLocalAddr { source })?;
608
609 let block_driver = BlockDriverTask::start(
610 scheduler_backend.scheduler_client().clone(),
611 block_driver_config,
612 );
613 let block_driver_trigger = block_driver.trigger();
614 let scheduler_backend = scheduler_backend.with_block_driver(block_driver_trigger.clone());
615 let local_airdrop = local_airdrop.map(|context| {
616 LocalAirdropRpc::new(
617 context,
618 scheduler_backend.clone(),
619 read.clone(),
620 block_driver_trigger,
621 )
622 });
623 let core_rpc = NativeService::new(
624 scheduler_backend.clone(),
625 scheduler_backend,
626 read,
627 view,
628 network_info.network_hash,
629 RpcServerConfig::default()
630 .with_view_gas_limit(view_gas_limit)
631 .with_view_timeout_ms(view_timeout_ms)
632 .with_view_batch(view_batch),
633 thruput_json_rpc_server::build_version!(),
634 NativeCapabilities::new(
635 true,
636 backend_planes.read.is_indexer_backed(),
637 matches!(backend_planes.chain, PlaneStatus::DbGateway),
638 matches!(backend_planes.view, PlaneStatus::ViewGateway),
639 ),
640 )
641 .with_local_pending_count();
642 let core_rpc = match bundle_identity.as_ref() {
643 Some(identity) => core_rpc.with_bundle_identity(identity),
644 None => core_rpc,
645 };
646 let module = match local_airdrop {
647 Some(funding) => core_rpc
648 .with_funding(funding)
649 .into_module()
650 .expect("bug: native methods register without conflicts")
651 .into_rpc()
652 .remove_context(),
653 None => core_rpc
654 .into_module()
655 .expect("bug: native methods register without conflicts")
656 .into_rpc()
657 .remove_context(),
658 };
659
660 let rpc_handle = server.start(module);
661 Ok(StartedRpcServer {
662 handle: rpc_handle,
663 addr: rpc_addr,
664 backend_planes,
665 block_driver,
666 })
667}
668
669const RPC_NOFILE_LIMIT_FLOOR: u64 = 4_096;
670const RPC_NOFILE_LIMIT_HEADROOM: u64 = 512;
671
672fn desired_rpc_nofile_limit(rpc_max_connections: u32) -> u64 {
673 u64::from(rpc_max_connections)
674 .saturating_add(RPC_NOFILE_LIMIT_HEADROOM)
675 .max(RPC_NOFILE_LIMIT_FLOOR)
676}
677
678#[cfg(unix)]
679fn ensure_rpc_nofile_limit(rpc_max_connections: u32) -> Result<(), NodeSupervisorError> {
680 let target = desired_rpc_nofile_limit(rpc_max_connections);
681 let mut limits = libc::rlimit {
682 rlim_cur: 0,
683 rlim_max: 0,
684 };
685 let rc = unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limits) };
686 if rc != 0 {
687 return Err(NodeSupervisorError::RpcNofileLimit {
688 target,
689 source: io::Error::last_os_error(),
690 });
691 }
692 let Some(new_soft) = rpc_nofile_soft_limit_update(target, limits)? else {
693 return Ok(());
694 };
695
696 let updated = libc::rlimit {
697 rlim_cur: new_soft,
698 rlim_max: limits.rlim_max,
699 };
700 let rc = unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &raw const updated) };
701 if rc != 0 {
702 return Err(NodeSupervisorError::RpcNofileLimit {
703 target,
704 source: io::Error::last_os_error(),
705 });
706 }
707 tracing::debug!(
708 previous = limits.rlim_cur,
709 current = new_soft,
710 hard = limits.rlim_max,
711 target,
712 "raised JSON-RPC nofile soft limit"
713 );
714 Ok(())
715}
716
717#[cfg(unix)]
718fn rpc_nofile_soft_limit_update(
719 target: u64,
720 limits: libc::rlimit,
721) -> Result<Option<libc::rlim_t>, NodeSupervisorError> {
722 let target_rlim = libc::rlim_t::try_from(target).unwrap_or(libc::RLIM_INFINITY);
723 if limits.rlim_cur >= target_rlim {
724 return Ok(None);
725 }
726 if limits.rlim_max != libc::RLIM_INFINITY && limits.rlim_max < target_rlim {
727 return Err(NodeSupervisorError::RpcNofileHardLimit {
728 target,
729 hard: limits.rlim_max,
730 });
731 }
732
733 if limits.rlim_max == libc::RLIM_INFINITY {
734 Ok(Some(target_rlim))
735 } else {
736 Ok(Some(target_rlim.min(limits.rlim_max)))
737 }
738}
739
740#[cfg(not(unix))]
741fn ensure_rpc_nofile_limit(_rpc_max_connections: u32) -> Result<(), NodeSupervisorError> {
742 Ok(())
743}
744
745#[derive(Debug)]
746struct StartedRpcServer {
747 handle: ServerHandle,
748 addr: SocketAddr,
749 backend_planes: BackendPlanes,
750 block_driver: BlockDriverTask,
751}
752
753#[cfg(test)]
754mod tests;