Skip to main content

bamboo_server/service_manager/
input.rs

1//! Generation-bound, non-blocking stdin delivery for supervised services.
2//!
3//! A public [`ServiceInputSender`] is a capability for exactly one spawned
4//! process. It never follows a restart: retiring that process first marks all
5//! clones stale/stopped, then cancels and joins the sole writer task that owns
6//! its `ChildStdin`. A replacement process gets a fresh channel, writer, and
7//! monotonically increasing generation.
8
9use std::io::Write as StdWrite;
10use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
11use std::sync::Arc;
12
13use bamboo_plugin::manifest::ServiceInputProtocol;
14use serde::Serialize;
15use tokio::io::{AsyncWrite, AsyncWriteExt};
16use tokio::process::Child;
17use tokio::sync::{mpsc, RwLock};
18use tokio_util::sync::CancellationToken;
19
20/// The fixed second-stage queue between a live service-generation handle and
21/// its stdin writer. Event sinks have their own independently validated queue
22/// in #905; this small bound prevents even an in-process caller of the service
23/// input API from accumulating unbounded writes behind a blocked child.
24pub const DEFAULT_SERVICE_INPUT_QUEUE_CAPACITY: usize = 64;
25/// Hard cap for one physical NDJSON line, including its trailing newline.
26/// Enforced during streaming serialization before queue admission,
27/// independently of any router/event-specific payload limit.
28pub const MAX_SERVICE_INPUT_LINE_BYTES: usize = 1024 * 1024;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum ServiceInputHealth {
33    /// Protocol declared, but no process generation is currently writable
34    /// (startup, restart backoff, or a crashed service).
35    Waiting,
36    Ready,
37    BrokenStdin,
38    Stopped,
39}
40
41/// Payload-free diagnostics for one supervised service's NDJSON input across
42/// all of its process generations. Only bounded counters and protocol state
43/// are exposed: no serialized values, OS error strings, environment, or paths.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct ServiceInputStatusSnapshot {
46    pub protocol: ServiceInputProtocol,
47    /// The currently bound generation. `None` means there is no writable
48    /// child right now; handles from prior generations remain invalid.
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub generation: Option<u64>,
51    pub health: ServiceInputHealth,
52    pub queue_capacity: usize,
53    pub max_line_bytes: usize,
54    pub accepted_lines: u64,
55    pub written_lines: u64,
56    pub dropped_queue_full: u64,
57    pub dropped_stale_generation: u64,
58    pub dropped_stopped: u64,
59    pub dropped_broken_stdin: u64,
60    pub serialization_failures: u64,
61    pub oversize_lines: u64,
62    pub write_failures: u64,
63}
64
65/// Immediate producer-side outcomes. None contains the payload or an
66/// underlying serde/OS error, so callers may safely expose or aggregate it.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
68pub enum ServiceInputSendError {
69    #[error("service input generation {generation} is stale")]
70    StaleGeneration { generation: u64 },
71    #[error("service input generation {generation} is stopped")]
72    Stopped { generation: u64 },
73    #[error("service input generation {generation} has broken stdin")]
74    BrokenStdin { generation: u64 },
75    #[error("service input generation {generation} queue is full")]
76    QueueFull { generation: u64 },
77    #[error("service input value could not be serialized as JSON")]
78    Serialization,
79    #[error("service input line exceeds the {max_bytes}-byte limit")]
80    Oversize { max_bytes: usize },
81}
82
83#[derive(Default)]
84struct ServiceInputCounters {
85    accepted_lines: AtomicU64,
86    written_lines: AtomicU64,
87    dropped_queue_full: AtomicU64,
88    dropped_stale_generation: AtomicU64,
89    dropped_stopped: AtomicU64,
90    dropped_broken_stdin: AtomicU64,
91    serialization_failures: AtomicU64,
92    oversize_lines: AtomicU64,
93    write_failures: AtomicU64,
94}
95
96fn increment(counter: &AtomicU64) {
97    // Diagnostics must remain monotonic even at the integer boundary rather
98    // than wrapping to zero and misrepresenting a heavily degraded service.
99    let _ = counter.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
100        Some(value.saturating_add(1))
101    });
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105#[repr(u8)]
106enum GenerationState {
107    Active = 0,
108    Stale = 1,
109    Stopped = 2,
110    BrokenStdin = 3,
111}
112
113impl GenerationState {
114    fn from_u8(value: u8) -> Self {
115        match value {
116            0 => Self::Active,
117            1 => Self::Stale,
118            2 => Self::Stopped,
119            3 => Self::BrokenStdin,
120            _ => Self::BrokenStdin,
121        }
122    }
123}
124
125struct GenerationMeta {
126    generation: u64,
127    state: AtomicU8,
128    counters: Arc<ServiceInputCounters>,
129}
130
131impl GenerationMeta {
132    fn state(&self) -> GenerationState {
133        GenerationState::from_u8(self.state.load(Ordering::SeqCst))
134    }
135
136    /// Writer ownership is narrow: it may report only Active -> BrokenStdin.
137    fn mark_broken_stdin(&self) {
138        let _ = self.state.compare_exchange(
139            GenerationState::Active as u8,
140            GenerationState::BrokenStdin as u8,
141            Ordering::SeqCst,
142            Ordering::SeqCst,
143        );
144    }
145
146    /// Process exit/restart retires Active OR Broken as stale, but may not
147    /// downgrade the higher-priority intentional Stopped terminal state.
148    fn mark_stale(&self) {
149        let _ = self
150            .state
151            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
152                (GenerationState::from_u8(current) != GenerationState::Stopped)
153                    .then_some(GenerationState::Stale as u8)
154            });
155    }
156
157    /// Intentional stop/upgrade/uninstall has highest priority and overrides
158    /// Active, Broken, or a concurrently published Stale state.
159    fn mark_stopped(&self) {
160        let _ = self
161            .state
162            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
163                (GenerationState::from_u8(current) != GenerationState::Stopped)
164                    .then_some(GenerationState::Stopped as u8)
165            });
166    }
167}
168
169struct CappedJsonLineWriter {
170    bytes: Vec<u8>,
171    max_json_bytes: usize,
172    exceeded: bool,
173}
174
175impl CappedJsonLineWriter {
176    fn new() -> Self {
177        Self {
178            // Keep one spare byte throughout serialization so appending the
179            // final newline never triggers an amortized growth beyond the
180            // declared line bound.
181            bytes: Vec::with_capacity(1),
182            max_json_bytes: MAX_SERVICE_INPUT_LINE_BYTES.saturating_sub(1),
183            exceeded: false,
184        }
185    }
186
187    fn finish(mut self) -> Vec<u8> {
188        // One byte was reserved from the cap specifically for the delimiter.
189        self.bytes.push(b'\n');
190        self.bytes
191    }
192}
193
194impl StdWrite for CappedJsonLineWriter {
195    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
196        let remaining = self.max_json_bytes.saturating_sub(self.bytes.len());
197        if buf.len() > remaining {
198            self.exceeded = true;
199            return Err(std::io::Error::other("service input line exceeds limit"));
200        }
201        let spare = self.bytes.capacity().saturating_sub(self.bytes.len());
202        let needed_spare = buf.len().saturating_add(1);
203        if needed_spare > spare {
204            self.bytes
205                .try_reserve_exact(needed_spare - spare)
206                .map_err(|_| std::io::Error::other("service input allocation failed"))?;
207        }
208        self.bytes.extend_from_slice(buf);
209        Ok(buf.len())
210    }
211
212    fn flush(&mut self) -> std::io::Result<()> {
213        Ok(())
214    }
215}
216
217/// Cloneable, non-blocking send capability for one exact process generation.
218///
219/// Serialization happens on the caller and the bounded queue is entered only
220/// with `try_send`; this API never awaits service I/O or queue capacity.
221/// A successful send means queue admission, not durable delivery: stopping or
222/// restarting the generation may discard records that the writer has not yet
223/// completed. `written_lines` advances only after a full write and flush.
224#[derive(Clone)]
225pub struct ServiceInputSender {
226    meta: Arc<GenerationMeta>,
227    tx: mpsc::Sender<Vec<u8>>,
228}
229
230impl ServiceInputSender {
231    pub fn generation(&self) -> u64 {
232        self.meta.generation
233    }
234
235    fn reject_for_state(&self) -> Result<(), ServiceInputSendError> {
236        let generation = self.generation();
237        match self.meta.state() {
238            GenerationState::Active => Ok(()),
239            GenerationState::Stale => {
240                increment(&self.meta.counters.dropped_stale_generation);
241                Err(ServiceInputSendError::StaleGeneration { generation })
242            }
243            GenerationState::Stopped => {
244                increment(&self.meta.counters.dropped_stopped);
245                Err(ServiceInputSendError::Stopped { generation })
246            }
247            GenerationState::BrokenStdin => {
248                increment(&self.meta.counters.dropped_broken_stdin);
249                Err(ServiceInputSendError::BrokenStdin { generation })
250            }
251        }
252    }
253
254    /// Serialize one value and enqueue exactly one newline-terminated JSON
255    /// record. Newlines inside strings remain JSON escapes, so one accepted
256    /// call always corresponds to one physical NDJSON line.
257    pub fn try_send<T>(&self, value: &T) -> Result<(), ServiceInputSendError>
258    where
259        T: Serialize + ?Sized,
260    {
261        self.reject_for_state()?;
262        let mut writer = CappedJsonLineWriter::new();
263        if serde_json::to_writer(&mut writer, value).is_err() {
264            if writer.exceeded {
265                increment(&self.meta.counters.oversize_lines);
266                return Err(ServiceInputSendError::Oversize {
267                    max_bytes: MAX_SERVICE_INPUT_LINE_BYTES,
268                });
269            }
270            increment(&self.meta.counters.serialization_failures);
271            return Err(ServiceInputSendError::Serialization);
272        }
273        let line = writer.finish();
274
275        // Serialization can invoke arbitrary user code. Re-check the lease
276        // afterwards so a generation retired during serialization cannot be
277        // enqueued into its now-closing writer.
278        self.reject_for_state()?;
279        match self.tx.try_send(line) {
280            Ok(()) => {
281                increment(&self.meta.counters.accepted_lines);
282                Ok(())
283            }
284            Err(mpsc::error::TrySendError::Full(_)) => {
285                increment(&self.meta.counters.dropped_queue_full);
286                Err(ServiceInputSendError::QueueFull {
287                    generation: self.generation(),
288                })
289            }
290            Err(mpsc::error::TrySendError::Closed(_)) => {
291                // A closed receiver while the lease still said Active means
292                // the sole writer ended unexpectedly. Publish the safe state
293                // before classifying this and subsequent producer attempts.
294                self.meta.mark_broken_stdin();
295                self.reject_for_state()
296            }
297        }
298    }
299
300    #[cfg(test)]
301    pub(super) fn remaining_capacity(&self) -> usize {
302        self.tx.capacity()
303    }
304}
305
306struct ActiveServiceInput {
307    sender: ServiceInputSender,
308    cancel: CancellationToken,
309}
310
311/// Runtime-wide input registry/counters. The active slot contains at most one
312/// generation and is consulted only while reconciling lifecycle/status, never
313/// by the producer hot path after it has acquired a `ServiceInputSender`.
314pub(super) struct ServiceInputRuntime {
315    service_id: String,
316    next_generation: Arc<AtomicU64>,
317    counters: Arc<ServiceInputCounters>,
318    active: RwLock<Option<ActiveServiceInput>>,
319}
320
321impl ServiceInputRuntime {
322    pub(super) fn new(service_id: String, next_generation: Arc<AtomicU64>) -> Self {
323        Self {
324            service_id,
325            next_generation,
326            counters: Arc::new(ServiceInputCounters::default()),
327            active: RwLock::new(None),
328        }
329    }
330
331    pub(super) async fn sender(&self) -> Option<ServiceInputSender> {
332        self.active
333            .read()
334            .await
335            .as_ref()
336            .map(|active| active.sender.clone())
337    }
338
339    pub(super) async fn bind_child(
340        &self,
341        child: &mut Child,
342    ) -> Result<BoundServiceInput, ServiceInputBindError> {
343        let stdin = child
344            .stdin
345            .take()
346            .ok_or(ServiceInputBindError::MissingStdinPipe)?;
347        self.bind_writer(stdin, DEFAULT_SERVICE_INPUT_QUEUE_CAPACITY)
348            .await
349    }
350
351    async fn bind_writer<W>(
352        &self,
353        writer: W,
354        queue_capacity: usize,
355    ) -> Result<BoundServiceInput, ServiceInputBindError>
356    where
357        W: AsyncWrite + Unpin + Send + 'static,
358    {
359        let mut active = self.active.write().await;
360        if active.is_some() {
361            return Err(ServiceInputBindError::GenerationAlreadyBound);
362        }
363
364        let generation = self
365            .next_generation
366            .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
367                current.checked_add(1)
368            })
369            .map(|previous| previous + 1)
370            .map_err(|_| ServiceInputBindError::GenerationExhausted)?;
371        let meta = Arc::new(GenerationMeta {
372            generation,
373            state: AtomicU8::new(GenerationState::Active as u8),
374            counters: self.counters.clone(),
375        });
376        let cancel = CancellationToken::new();
377        let (tx, rx) = mpsc::channel(queue_capacity.max(1));
378        let sender = ServiceInputSender {
379            meta: meta.clone(),
380            tx,
381        };
382        let writer_cancel = cancel.clone();
383        let service_id = self.service_id.clone();
384        let task = tokio::spawn(run_writer(service_id, meta, writer_cancel, writer, rx));
385
386        *active = Some(ActiveServiceInput {
387            sender: sender.clone(),
388            cancel: cancel.clone(),
389        });
390        Ok(BoundServiceInput {
391            sender,
392            cancel,
393            task: Some(task),
394        })
395    }
396
397    /// Invalidate the public generation before the supervisor is awakened.
398    /// `stop_service` uses this ordering so upgrade/uninstall cannot enqueue
399    /// more input after they have begun stopping the old binary.
400    pub(super) async fn stop_active(&self) {
401        let active = self.active.write().await.take();
402        if let Some(active) = active {
403            active.sender.meta.mark_stopped();
404            active.cancel.cancel();
405        }
406    }
407
408    async fn retire_generation(&self, generation: u64, stopped: bool) {
409        let mut active = self.active.write().await;
410        if active
411            .as_ref()
412            .is_some_and(|active| active.sender.generation() == generation)
413        {
414            let active = active.take().expect("checked active generation");
415            if stopped {
416                active.sender.meta.mark_stopped();
417            } else {
418                active.sender.meta.mark_stale();
419            }
420            active.cancel.cancel();
421        }
422    }
423
424    pub(super) async fn snapshot(&self, stopped: bool) -> ServiceInputStatusSnapshot {
425        let active = self.active.read().await;
426        let (generation, health) = match active.as_ref() {
427            Some(active) => (
428                Some(active.sender.generation()),
429                match active.sender.meta.state() {
430                    GenerationState::Active => ServiceInputHealth::Ready,
431                    GenerationState::BrokenStdin => ServiceInputHealth::BrokenStdin,
432                    GenerationState::Stale => ServiceInputHealth::Waiting,
433                    GenerationState::Stopped => ServiceInputHealth::Stopped,
434                },
435            ),
436            None if stopped => (None, ServiceInputHealth::Stopped),
437            None => (None, ServiceInputHealth::Waiting),
438        };
439        ServiceInputStatusSnapshot {
440            protocol: ServiceInputProtocol::NdjsonV1,
441            generation,
442            health,
443            queue_capacity: DEFAULT_SERVICE_INPUT_QUEUE_CAPACITY,
444            max_line_bytes: MAX_SERVICE_INPUT_LINE_BYTES,
445            accepted_lines: self.counters.accepted_lines.load(Ordering::Relaxed),
446            written_lines: self.counters.written_lines.load(Ordering::Relaxed),
447            dropped_queue_full: self.counters.dropped_queue_full.load(Ordering::Relaxed),
448            dropped_stale_generation: self
449                .counters
450                .dropped_stale_generation
451                .load(Ordering::Relaxed),
452            dropped_stopped: self.counters.dropped_stopped.load(Ordering::Relaxed),
453            dropped_broken_stdin: self.counters.dropped_broken_stdin.load(Ordering::Relaxed),
454            serialization_failures: self.counters.serialization_failures.load(Ordering::Relaxed),
455            oversize_lines: self.counters.oversize_lines.load(Ordering::Relaxed),
456            write_failures: self.counters.write_failures.load(Ordering::Relaxed),
457        }
458    }
459
460    #[cfg(test)]
461    pub(super) async fn bind_writer_for_test<W>(
462        &self,
463        writer: W,
464        queue_capacity: usize,
465    ) -> Result<BoundServiceInput, ServiceInputBindError>
466    where
467        W: AsyncWrite + Unpin + Send + 'static,
468    {
469        self.bind_writer(writer, queue_capacity).await
470    }
471
472    #[cfg(test)]
473    pub(super) async fn bind_child_for_test(
474        &self,
475        child: &mut Child,
476        queue_capacity: usize,
477    ) -> Result<BoundServiceInput, ServiceInputBindError> {
478        let stdin = child
479            .stdin
480            .take()
481            .ok_or(ServiceInputBindError::MissingStdinPipe)?;
482        self.bind_writer(stdin, queue_capacity).await
483    }
484}
485
486#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
487pub(super) enum ServiceInputBindError {
488    #[error("spawned NDJSON service has no stdin pipe")]
489    MissingStdinPipe,
490    #[error("service input generation is already bound")]
491    GenerationAlreadyBound,
492    #[error("service input generation space is exhausted")]
493    GenerationExhausted,
494}
495
496pub(super) struct BoundServiceInput {
497    sender: ServiceInputSender,
498    cancel: CancellationToken,
499    task: Option<tokio::task::JoinHandle<()>>,
500}
501
502impl BoundServiceInput {
503    pub(super) fn generation(&self) -> u64 {
504        self.sender.generation()
505    }
506
507    /// Unpublish this exact generation, cancel any blocked write, and await
508    /// task exit so its stdin is closed before process shutdown/replacement.
509    pub(super) async fn close(mut self, runtime: &ServiceInputRuntime, stopped: bool) {
510        runtime.retire_generation(self.generation(), stopped).await;
511        // `stop_service` may already have removed the active slot; these are
512        // deliberately idempotent and still cover that ordering.
513        if stopped {
514            self.sender.meta.mark_stopped();
515        } else {
516            self.sender.meta.mark_stale();
517        }
518        self.cancel.cancel();
519        if let Some(task) = self.task.take() {
520            let _ = task.await;
521        }
522    }
523}
524
525impl Drop for BoundServiceInput {
526    fn drop(&mut self) {
527        // Covers supervisor abort/panic: never detach the sole ChildStdin
528        // owner. `mark_stale` respects an already-published Stopped state.
529        self.sender.meta.mark_stale();
530        self.cancel.cancel();
531        if let Some(task) = self.task.take() {
532            task.abort();
533        }
534    }
535}
536
537async fn run_writer<W>(
538    service_id: String,
539    meta: Arc<GenerationMeta>,
540    cancel: CancellationToken,
541    mut writer: W,
542    mut rx: mpsc::Receiver<Vec<u8>>,
543) where
544    W: AsyncWrite + Unpin,
545{
546    loop {
547        let line = tokio::select! {
548            biased;
549            _ = cancel.cancelled() => break,
550            line = rx.recv() => match line {
551                Some(line) => line,
552                None => break,
553            },
554        };
555
556        let result = tokio::select! {
557            biased;
558            _ = cancel.cancelled() => break,
559            result = async {
560                writer.write_all(&line).await?;
561                writer.flush().await
562            } => result,
563        };
564        match result {
565            Ok(()) => increment(&meta.counters.written_lines),
566            Err(error) => {
567                increment(&meta.counters.write_failures);
568                meta.mark_broken_stdin();
569                // Only the coarse error kind is retained in logs; values and
570                // platform-specific error strings can contain sensitive paths.
571                tracing::warn!(
572                    service_id = %service_id,
573                    generation = meta.generation,
574                    error_kind = ?error.kind(),
575                    "service NDJSON stdin writer stopped"
576                );
577                break;
578            }
579        }
580    }
581    rx.close();
582    // `writer` (and therefore ChildStdin) drops here, delivering EOF before
583    // the supervisor signals or replaces the process.
584}