Skip to main content

spate_core/pipeline/
runtime.rs

1//! Process assembly: threads, runtimes, observability, and the run loop.
2
3use super::controller::{ControllerContext, ControllerSignal, run_controller};
4use super::driver::{DriverContext, DriverExit, DriverParams, run_driver};
5use super::{DriverEvent, ExitReport, ExitState, FatalErrorReport, SinkRuntime, ThreadControl};
6use crate::admin::{AdminServer, HealthState, HealthThresholds};
7use crate::backpressure::{BackpressureParams, InflightBudget, WatermarkController};
8use crate::checkpoint::Checkpointer;
9use crate::config::{MetricsExporter, PinningMode, PipelineConfig};
10use crate::metrics::{
11    self, BackpressureMetrics, CheckpointMetrics, ComponentLabels, E2eBasis, Exporter, Meter,
12    MetricRole, MetricsHandle, MetricsSettings, PipelineMetrics, PipelineState, SourceMetrics,
13};
14use crate::ops::RunnableChain;
15use crate::source::{DrainBarrier, Source};
16use std::sync::Arc;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::time::{Duration, Instant};
19
20/// Knobs that are not part of the user-facing YAML (loop granularities,
21/// test hooks). The defaults suit production; tests shrink the timings.
22#[derive(Clone, Debug)]
23pub struct RuntimeOptions {
24    /// Install SIGTERM/SIGINT handlers that trigger a graceful drain.
25    /// Disable in tests and drive [`ShutdownHandle`] instead.
26    pub handle_signals: bool,
27    /// Max payloads per lane poll.
28    pub max_records: usize,
29    /// Lane poll timeout (also the paused/idle loop sleep).
30    pub poll_timeout: Duration,
31    /// Flush the chain after this long without new data.
32    pub idle_flush: Duration,
33    /// Sleep between retries of a blocked batch.
34    pub blocked_retry: Duration,
35    /// Controller `poll_events` timeout.
36    pub event_poll_timeout: Duration,
37    /// Version string published on `spate_pipeline_info`.
38    pub version: String,
39}
40
41impl Default for RuntimeOptions {
42    fn default() -> Self {
43        RuntimeOptions {
44            handle_signals: true,
45            max_records: 512,
46            poll_timeout: Duration::from_millis(10),
47            idle_flush: Duration::from_millis(100),
48            blocked_retry: Duration::from_millis(2),
49            event_poll_timeout: Duration::from_millis(50),
50            version: env!("CARGO_PKG_VERSION").to_string(),
51        }
52    }
53}
54
55/// Triggers a graceful drain from anywhere (tests, custom signal wiring).
56#[derive(Clone, Debug)]
57pub struct ShutdownHandle(Arc<AtomicBool>);
58
59impl ShutdownHandle {
60    /// Begin the drain. Idempotent.
61    pub fn trigger(&self) {
62        self.0.store(true, Ordering::Relaxed);
63    }
64}
65
66/// The pipeline could not start.
67#[derive(Debug, thiserror::Error)]
68#[non_exhaustive]
69pub enum StartError {
70    /// Invalid effective configuration.
71    #[error("invalid runtime configuration: {0}")]
72    Config(String),
73    /// The metrics exporter could not be installed.
74    #[error("metrics: {0}")]
75    Metrics(String),
76    /// The I/O runtime or the admin server could not start.
77    #[error("io: {0}")]
78    Io(#[from] std::io::Error),
79}
80
81/// One pipeline process: source, per-thread chains, and a sink, assembled
82/// per `docs/DESIGN.md` § Process anatomy.
83///
84/// The caller creates the shared [`InflightBudget`] first and wires it into
85/// the chain terminals (which `add` on enqueue) and the sink workers (which
86/// `sub` on durable write or abandonment) before handing everything here.
87pub struct PipelineRuntime<S: Source> {
88    config: PipelineConfig,
89    source: S,
90    chains: Box<dyn FnMut(usize) -> Box<dyn RunnableChain> + Send>,
91    sink: SinkRuntime,
92    budget: Arc<InflightBudget>,
93    shutdown: Arc<AtomicBool>,
94    options: RuntimeOptions,
95    io: Option<tokio::runtime::Runtime>,
96}
97
98impl<S: Source> std::fmt::Debug for PipelineRuntime<S> {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.debug_struct("PipelineRuntime")
101            .field("pipeline", &self.config.pipeline.name)
102            .field("options", &self.options)
103            .finish_non_exhaustive()
104    }
105}
106
107impl<S: Source + 'static> PipelineRuntime<S> {
108    /// Assemble a runtime. `chains` builds one erased chain per pipeline
109    /// thread (thread index in).
110    pub fn new(
111        config: PipelineConfig,
112        source: S,
113        chains: impl FnMut(usize) -> Box<dyn RunnableChain> + Send + 'static,
114        sink: SinkRuntime,
115        budget: Arc<InflightBudget>,
116    ) -> Self {
117        PipelineRuntime {
118            config,
119            source,
120            chains: Box::new(chains),
121            sink,
122            budget,
123            shutdown: Arc::new(AtomicBool::new(false)),
124            options: RuntimeOptions::default(),
125            io: None,
126        }
127    }
128
129    /// Override runtime options (returns `self` for chaining).
130    #[must_use]
131    pub fn with_options(mut self, options: RuntimeOptions) -> Self {
132        self.options = options;
133        self
134    }
135
136    /// Use a caller-owned tokio runtime as the I/O runtime instead of
137    /// building one inside [`run`](Self::run) — for assemblies whose
138    /// connectors needed a handle before the runtime existed (sink workers
139    /// spawned at construction, schema-registry fetchers, async pre-flight
140    /// validation). `run` shuts it down on exit exactly as it does the
141    /// internally built one; connector tasks spawned on it earlier keep
142    /// running until then. Without this, assemblies end up running a
143    /// second runtime, doubling `pipeline.io_threads`.
144    #[must_use]
145    pub fn with_io_runtime(mut self, io: tokio::runtime::Runtime) -> Self {
146        self.io = Some(io);
147        self
148    }
149
150    /// A handle that triggers a graceful drain.
151    #[must_use]
152    pub fn shutdown_handle(&self) -> ShutdownHandle {
153        ShutdownHandle(Arc::clone(&self.shutdown))
154    }
155
156    /// Effective pipeline thread count: the config override, else
157    /// `available_parallelism` minus the I/O reserve (I/O workers + the
158    /// controller), at least 1. `available_parallelism` respects cgroup
159    /// CPU quotas, so Kubernetes limits size this correctly; pods without
160    /// limits see the node's cores — set `pipeline.threads` explicitly
161    /// there.
162    fn thread_count(&self) -> usize {
163        self.config.pipeline.threads.unwrap_or_else(|| {
164            let cores = std::thread::available_parallelism().map_or(2, usize::from);
165            cores
166                .saturating_sub(self.config.pipeline.io_threads + 1)
167                .max(1)
168        })
169    }
170
171    /// Run the pipeline to completion (blocking). Returns when the
172    /// pipeline drained after a shutdown trigger/signal or failed.
173    pub fn run(mut self) -> Result<ExitReport, StartError> {
174        let threads = self.thread_count();
175        if threads == 0 || self.config.pipeline.io_threads == 0 {
176            return Err(StartError::Config("thread counts must be non-zero".into()));
177        }
178        let pipeline_name = self.config.pipeline.name.clone();
179
180        // The source's declared component_type feeds both its stage-metric
181        // labels and the namespace of the custom-metrics Meter it receives at
182        // `open`. Captured here, before `self.source` moves into the controller.
183        let source_ct = self.source.component_type().to_string();
184        let source_meter = Meter::for_component(
185            &source_ct,
186            MetricRole::Source,
187            pipeline_name.clone(),
188            "source",
189        );
190
191        // Observability first: everything after this records metrics.
192        let handle = install_or_reuse(&metrics_settings(&self.config))?;
193
194        let runtime_labels = ComponentLabels::new(pipeline_name.clone(), "runtime", "pipeline");
195        // Fallible: a second live pipeline of this name in one process would
196        // publish state and lifecycle gauges over this one's (see "Series
197        // ownership" in `docs/METRICS.md`). Refuse to start instead.
198        let pipeline_metrics = PipelineMetrics::try_new(&runtime_labels, &self.options.version)
199            .map_err(|e| StartError::Metrics(e.to_string()))?;
200        pipeline_metrics.set_state(PipelineState::Starting);
201        pipeline_metrics.set_threads(threads);
202
203        // The controller's handle sets, resolved here rather than at the
204        // controller's own construction below: both claim series, and a
205        // duplicate must fail before any driver thread exists — past that
206        // point a bare `?` would leak the threads instead of stopping them.
207        let checkpoint_metrics = CheckpointMetrics::try_new(
208            &ComponentLabels::new(pipeline_name.clone(), "checkpoint", "checkpoint"),
209            self.config.metrics.per_partition_detail,
210        )
211        .map_err(|e| StartError::Metrics(e.to_string()))?;
212        // The owner of the source series: it publishes active lanes, and its
213        // clone goes to the source, which is the only thing that can measure
214        // lag. The per-thread instances are shadows of this one.
215        let controller_source_metrics = Arc::new(
216            SourceMetrics::try_new(&ComponentLabels::new(
217                pipeline_name.clone(),
218                "source",
219                source_ct.clone(),
220            ))
221            .map_err(|e| StartError::Metrics(e.to_string()))?,
222        );
223
224        let health = HealthState::new(threads, HealthThresholds::default());
225
226        // I/O runtime: sink workers (spawned by the caller-built SinkPool
227        // onto this runtime via its own handle), admin server, upkeep,
228        // signals. A caller-owned runtime (`with_io_runtime`) is adopted
229        // instead of built; either way this function owns its shutdown.
230        let io = match self.io.take() {
231            Some(io) => io,
232            None => tokio::runtime::Builder::new_multi_thread()
233                .worker_threads(self.config.pipeline.io_threads)
234                .thread_name("spate-io")
235                .enable_all()
236                .build()?,
237        };
238
239        // Admin bind, upkeep, and the controller thread are started *after*
240        // the driver threads (below) so a failure in any of them can stop
241        // the already-running drivers instead of leaking them.
242
243        if self.options.handle_signals {
244            let shutdown = Arc::clone(&self.shutdown);
245            io.spawn(async move {
246                wait_for_signal().await;
247                tracing::info!("shutdown signal received; draining");
248                shutdown.store(true, Ordering::Relaxed);
249            });
250        }
251
252        // Sink readiness: probe at startup and periodically (tighter while
253        // failing), driving the sinks-connected half of `/readyz`. No probe
254        // hook means nothing to check — report connected.
255        match self.sink.probe.take() {
256            Some(probe) => {
257                let health_probe = Arc::clone(&health);
258                io.spawn(async move {
259                    loop {
260                        let connected = match probe().await {
261                            Ok(()) => true,
262                            Err(e) => {
263                                tracing::warn!(error = %e, "sink probe failed");
264                                false
265                            }
266                        };
267                        health_probe.set_sinks_connected(connected);
268                        let recheck = if connected {
269                            Duration::from_secs(30)
270                        } else {
271                            Duration::from_secs(5)
272                        };
273                        tokio::time::sleep(recheck).await;
274                    }
275                });
276            }
277            None => health.set_sinks_connected(true),
278        }
279
280        // Wiring.
281        let (events_tx, events_rx) = crossbeam_channel::unbounded::<DriverEvent>();
282        let (to_main_tx, to_main_rx) = crossbeam_channel::unbounded::<ControllerSignal>();
283        let (sink_drained_tx, sink_drained_rx) = crossbeam_channel::unbounded::<()>();
284        let checkpointer = Checkpointer::new();
285
286        let bp_params = BackpressureParams::from_budget(
287            usize::try_from(self.config.backpressure.max_inflight_bytes.as_u64())
288                .unwrap_or(usize::MAX),
289            self.config.backpressure.high_ratio,
290            self.config.backpressure.low_ratio,
291            self.config.backpressure.min_pause,
292        );
293
294        // Compact pinning: thread i on core i, low cores first, leaving the
295        // remaining cores for the I/O runtime and librdkafka's threads.
296        // Note for Kubernetes: exclusive cores require the kubelet static
297        // CPU manager with Guaranteed QoS and integer CPU requests;
298        // otherwise pinning only sets affinity within the shared cpuset.
299        let core_ids: Vec<Option<core_affinity::CoreId>> =
300            if self.config.pipeline.pinning == PinningMode::Compact {
301                let mut ids = core_affinity::get_core_ids().unwrap_or_default();
302                ids.sort_by_key(|c| c.id);
303                if ids.len() < threads {
304                    tracing::warn!(
305                        cores = ids.len(),
306                        threads,
307                        "fewer cores than pipeline threads; surplus threads run unpinned"
308                    );
309                }
310                (0..threads).map(|i| ids.get(i).copied()).collect()
311            } else {
312                vec![None; threads]
313            };
314
315        // Short grace for cleanup-time driver stops (startup errors, a
316        // controller panic): the same budget the drain barrier uses.
317        let drain_timeout = self.config.checkpoint.drain_timeout;
318
319        let mut control_txs = Vec::with_capacity(threads);
320        let mut driver_handles = Vec::with_capacity(threads);
321        for i in 0..threads {
322            let (control_tx, control_rx) = crossbeam_channel::unbounded::<ThreadControl<S::Lane>>();
323            control_txs.push(control_tx);
324            let ctx = DriverContext {
325                params: DriverParams {
326                    thread: i,
327                    max_records: self.options.max_records,
328                    poll_timeout: self.options.poll_timeout,
329                    idle_flush: self.options.idle_flush,
330                    blocked_retry: self.options.blocked_retry,
331                    queue_low_ratio: self.config.backpressure.low_ratio,
332                },
333                control: control_rx,
334                events: events_tx.clone(),
335                chain: (self.chains)(i),
336                bp: WatermarkController::new(bp_params),
337                budget: Arc::clone(&self.budget),
338                queues: self.sink.queues.clone(),
339                health: Arc::clone(&health),
340                // Each driver's backpressure series is its own (`driver-{i}`),
341                // so every thread owns what it publishes.
342                bp_metrics: BackpressureMetrics::new(&ComponentLabels::new(
343                    pipeline_name.clone(),
344                    format!("driver-{i}"),
345                    "driver",
346                )),
347                // Deliberately a shadow: every thread counts the records and
348                // polls it performed (those sum), but the source *gauges* —
349                // lag and active lanes — belong to the controller's instance
350                // below, which is the only one that sees the assignment and
351                // the only one the source itself is handed. A claiming
352                // constructor here would take the series hostage from it.
353                source_metrics: SourceMetrics::shadow(&ComponentLabels::new(
354                    pipeline_name.clone(),
355                    "source",
356                    source_ct.clone(),
357                )),
358                shutdown: Arc::clone(&self.shutdown),
359            };
360            let core = core_ids.get(i).copied().flatten();
361            let spawned = std::thread::Builder::new()
362                .name(format!("spate-pipeline-{i}"))
363                .spawn(move || {
364                    if let Some(core) = core
365                        && !core_affinity::set_for_current(core)
366                    {
367                        tracing::warn!(core = core.id, "failed to pin pipeline thread");
368                    }
369                    run_driver(ctx)
370                });
371            match spawned {
372                Ok(handle) => driver_handles.push(handle),
373                Err(e) => {
374                    // Stop the drivers already spawned before bailing out.
375                    stop_drivers(&self.shutdown, &control_txs, driver_handles, drain_timeout);
376                    return Err(StartError::Io(e));
377                }
378            }
379        }
380
381        // The chain factory has served its purpose. Factories naturally
382        // capture ShardQueues clones (their terminals need them), and the
383        // sink only drains once every queue clone is gone — holding the
384        // factory through the drain would deadlock shutdown.
385        drop(self.chains);
386
387        // A cloned set of driver control senders kept by main, so it can stop
388        // the drivers itself if a later startup step fails or the controller
389        // thread dies (the originals are moved into the controller below).
390        let control_txs_for_stop = control_txs.clone();
391
392        // Admin bind now that the drivers are live: a bind failure (e.g. the
393        // metrics port is taken) stops them instead of leaking them.
394        let admin = match io.block_on(AdminServer::bind(
395            self.config.metrics.listen,
396            handle.render_fn(),
397            Arc::clone(&health),
398        )) {
399            Ok(admin) => admin,
400            Err(e) => {
401                stop_drivers(
402                    &self.shutdown,
403                    &control_txs_for_stop,
404                    driver_handles,
405                    drain_timeout,
406                );
407                return Err(StartError::Io(e));
408            }
409        };
410        let (admin_stop_tx, admin_stop_rx) = tokio::sync::watch::channel(false);
411        io.spawn(admin.run(admin_stop_rx));
412        {
413            // spawn_upkeep uses tokio::spawn internally; enter the runtime.
414            let _guard = io.enter();
415            let _upkeep = handle.spawn_upkeep(Duration::from_secs(5));
416        }
417
418        let controller_ctx = ControllerContext {
419            source: self.source,
420            checkpointer,
421            control_txs,
422            events_rx,
423            to_main: to_main_tx,
424            sink_drained_rx,
425            shutdown: Arc::clone(&self.shutdown),
426            health: Arc::clone(&health),
427            commit_interval: self.config.checkpoint.interval,
428            drain_timeout: self.config.checkpoint.drain_timeout,
429            event_poll_timeout: self.options.event_poll_timeout,
430            max_pending_batches: self.config.checkpoint.max_pending_batches,
431            stalled_fail_after: self.config.checkpoint.stalled_fail_after,
432            checkpoint_metrics,
433            source_metrics: controller_source_metrics,
434            source_meter,
435            per_partition_detail: self.config.metrics.per_partition_detail,
436            pipeline_metrics,
437        };
438        let controller_handle = match std::thread::Builder::new()
439            .name("spate-controller".into())
440            .spawn(move || run_controller(controller_ctx))
441        {
442            Ok(handle) => handle,
443            Err(e) => {
444                stop_drivers(
445                    &self.shutdown,
446                    &control_txs_for_stop,
447                    driver_handles,
448                    drain_timeout,
449                );
450                return Err(StartError::Io(e));
451            }
452        };
453
454        // Main: wait for the controller's choreography.
455        let mut sink_drain = None;
456        let mut driver_panic: Option<FatalErrorReport> = None;
457        let sink_runtime = self.sink;
458        let mut drain_fn = Some(sink_runtime.drain);
459        drop(sink_runtime.queues);
460
461        let (mut state, final_watermarks) = loop {
462            match to_main_rx.recv_timeout(Duration::from_millis(100)) {
463                Ok(ControllerSignal::LanesDrained { sink_deadline }) => {
464                    for (i, h) in driver_handles.drain(..).enumerate() {
465                        if h.join().is_err() {
466                            driver_panic.get_or_insert(FatalErrorReport {
467                                component: format!("driver-{i}"),
468                                reason: "pipeline thread panicked outside the batch guard".into(),
469                            });
470                        }
471                    }
472                    if let Some(drain) = drain_fn.take() {
473                        let budget = sink_deadline.saturating_duration_since(Instant::now());
474                        sink_drain = Some(io.block_on(drain(budget)));
475                    }
476                    let _ = sink_drained_tx.send(());
477                }
478                Ok(ControllerSignal::Finished(report)) => {
479                    break (report.state, report.final_watermarks);
480                }
481                Err(crossbeam_channel::RecvTimeoutError::Timeout)
482                    if !controller_handle.is_finished() =>
483                {
484                    // Controller still working; keep waiting on the 100ms tick.
485                }
486                Err(_) => {
487                    // The controller thread ended without a Finished report
488                    // (a timeout with a finished handle, or the signal
489                    // channel disconnected): it panicked. It never told the
490                    // drivers to stop and never set the shutdown flag, so an
491                    // untimed join here would wedge forever — stop them
492                    // ourselves, drain the sink, and fail the run.
493                    stop_drivers(
494                        &self.shutdown,
495                        &control_txs_for_stop,
496                        std::mem::take(&mut driver_handles),
497                        drain_timeout,
498                    );
499                    if let Some(drain) = drain_fn.take() {
500                        sink_drain = Some(io.block_on(drain(drain_timeout)));
501                    }
502                    break (
503                        ExitState::Failed(FatalErrorReport {
504                            component: "controller".into(),
505                            reason: "controller thread panicked".into(),
506                        }),
507                        Vec::new(),
508                    );
509                }
510            }
511        };
512        // Drivers are already joined on the drain path; on the
513        // controller-died path make a best effort not to leak them.
514        for h in driver_handles {
515            let _ = h.join();
516        }
517        // A driver that panicked outside the batch guard is a bug worth
518        // failing the run over, even if the drain otherwise completed.
519        if let (ExitState::Completed, Some(report)) = (&state, driver_panic) {
520            state = ExitState::Failed(report);
521        }
522
523        let _ = admin_stop_tx.send(true);
524        io.shutdown_timeout(Duration::from_secs(2));
525        let _ = controller_handle.join();
526
527        Ok(ExitReport {
528            state,
529            sink_drain,
530            final_watermarks,
531        })
532    }
533}
534
535/// Set the shutdown flag, tell every driver thread to stop within a bounded
536/// drain barrier, and join them. Shared by the startup-error paths and the
537/// controller-death path so an early failure or a controller panic never
538/// leaves running pinned pipeline threads behind (`run` is a library API).
539///
540/// Joining is what actually bounds this — a driver observes the shutdown
541/// flag (abandoning any blocked batch) and the `Shutdown` control message
542/// (flushing within `grace`), then exits and drops its chain, closing the
543/// shard queues so the sink can drain afterwards.
544fn stop_drivers<L>(
545    shutdown: &AtomicBool,
546    control_txs: &[crossbeam_channel::Sender<ThreadControl<L>>],
547    driver_handles: Vec<std::thread::JoinHandle<DriverExit>>,
548    grace: Duration,
549) {
550    shutdown.store(true, Ordering::Relaxed);
551    let deadline = Instant::now() + grace;
552    let barrier = DrainBarrier::new(control_txs.len());
553    for tx in control_txs {
554        let _ = tx.send(ThreadControl::Shutdown {
555            barrier: barrier.clone(),
556            deadline,
557        });
558    }
559    for handle in driver_handles {
560        let _ = handle.join();
561    }
562}
563
564/// Install the exporter, degrading gracefully when a foreign recorder
565/// already owns the process: the pipeline keeps running against the
566/// existing recorder with a detached (empty-rendering) handle for the
567/// admin server. Shared by the runtime and the pipeline builder.
568pub(crate) fn install_or_reuse(settings: &MetricsSettings) -> Result<MetricsHandle, StartError> {
569    match metrics::install(settings) {
570        Ok(h) => Ok(h),
571        Err(metrics::MetricsError::AlreadyInstalled) => {
572            tracing::warn!(
573                "a metrics recorder is already installed; continuing \
574                 with the existing one and a detached render handle"
575            );
576            metrics::install(&MetricsSettings {
577                exporter: Exporter::None,
578                ..settings.clone()
579            })
580            .map_err(|e| StartError::Metrics(e.to_string()))
581        }
582        Err(e) => Err(StartError::Metrics(e.to_string())),
583    }
584}
585
586/// The [`MetricsSettings`] a pipeline configuration maps to.
587///
588/// Assemblies that pre-register metric handles (sink shard metrics, custom
589/// metrics) should call
590/// [`metrics::install`](crate::metrics::install)`(&metrics_settings(&config))`
591/// **before** constructing them; the runtime's own install then reuses the
592/// exporter. Handles built before any install bind to the no-op recorder
593/// and render nothing.
594#[must_use]
595pub fn metrics_settings(config: &PipelineConfig) -> MetricsSettings {
596    MetricsSettings {
597        exporter: match config.metrics.exporter {
598            MetricsExporter::Prometheus => Exporter::Prometheus,
599            MetricsExporter::None => Exporter::None,
600        },
601        listen: config.metrics.listen,
602        per_partition_detail: config.metrics.per_partition_detail,
603        e2e_basis: match config.metrics.e2e_basis {
604            crate::config::E2eBasis::Ingest => E2eBasis::Ingest,
605            crate::config::E2eBasis::Event => E2eBasis::Event,
606        },
607    }
608}
609
610async fn wait_for_signal() {
611    #[cfg(unix)]
612    {
613        let mut term =
614            match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
615                Ok(s) => s,
616                Err(e) => {
617                    tracing::error!(error = %e, "failed to install SIGTERM handler");
618                    std::future::pending::<()>().await;
619                    return;
620                }
621            };
622        tokio::select! {
623            _ = term.recv() => {}
624            r = tokio::signal::ctrl_c() => {
625                if let Err(e) = r {
626                    tracing::error!(error = %e, "ctrl_c handler failed");
627                    std::future::pending::<()>().await;
628                }
629            }
630        }
631    }
632    #[cfg(not(unix))]
633    {
634        if let Err(e) = tokio::signal::ctrl_c().await {
635            tracing::error!(error = %e, "ctrl_c handler failed");
636            std::future::pending::<()>().await;
637        }
638    }
639}