Skip to main content

secretspec_ipc/
lifecycle.rs

1use crate::client::{CallbackHandler, Client};
2use crate::connection::{FilesystemAccess, SshOptions};
3use crate::deadline::instant_from_unix_ms;
4use crate::error::{ErrorKind, RpcError};
5use crate::protocol::callback;
6use crate::protocol::provider::{
7    self as provider_protocol, InitializeApplication as ProviderInitializeApplication,
8    InitializedApplication as ProviderInitializedApplication, Metadata,
9};
10use crate::protocol::resolver::{
11    self as resolver_protocol, InitializeApplication as ResolverInitializeApplication,
12    InitializedApplication as ResolverInitializedApplication,
13};
14use crate::protocol::{
15    InitializeParams, InitializeResult, Limits, PROTOCOL_VERSION, PROVIDER_PROTOCOL, Product,
16    RESOLVER_PROTOCOL,
17};
18use crate::{Error, Result, deadline_unix_ms_after};
19use serde::Serialize;
20use serde::de::DeserializeOwned;
21use std::collections::HashSet;
22use std::process::Stdio;
23use std::sync::Arc;
24use std::sync::atomic::{AtomicBool, Ordering};
25use std::time::Duration;
26use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite};
27use tokio::process::{Child, Command};
28use tokio::sync::Mutex;
29use tokio::task::JoinHandle;
30use tokio::time::Instant;
31use tokio_util::sync::CancellationToken;
32use zeroize::Zeroizing;
33
34/// Budget for reaping a child that had to be killed, and for draining its
35/// stderr afterwards. Both are bounded local operations, so they are measured
36/// from the moment they start rather than from the caller's shutdown deadline
37/// (which the graceful wait has already spent by then).
38const REAP_GRACE: Duration = Duration::from_secs(2);
39
40pub use crate::launch::{Environment, LaunchOptions};
41
42/// An owned child and its initialized wire session.
43pub struct ChildSession {
44    client: Client,
45    child: Arc<Mutex<Child>>,
46    monitor_cancel: CancellationToken,
47    monitor: Mutex<Option<JoinHandle<()>>>,
48    stderr: Mutex<Option<JoinHandle<Zeroizing<Vec<u8>>>>>,
49    closed: AtomicBool,
50}
51
52impl ChildSession {
53    pub fn client(&self) -> &Client {
54        &self.client
55    }
56
57    pub async fn close(&self, deadline_unix_ms: u64) -> Result<()> {
58        if self.closed.swap(true, Ordering::AcqRel) {
59            return Ok(());
60        }
61        let protocol_outcome = self.client.close(deadline_unix_ms).await;
62        self.monitor_cancel.cancel();
63        if let Some(monitor) = self.monitor.lock().await.take() {
64            let _ = monitor.await;
65        }
66
67        let requested = instant_from_unix_ms(deadline_unix_ms);
68        let cap = Instant::now() + Duration::from_secs(5);
69        let deadline = requested.min(cap);
70        let wait_outcome = wait_until(&self.child, deadline).await;
71        let mut kill_error = None;
72        if !matches!(&wait_outcome, Ok(true)) {
73            // The graceful wait above runs until `deadline` elapses, so reusing
74            // it here would leave the kill no budget at all and the child would
75            // never be reaped. Reaping a killed child is bounded work, so it
76            // gets its own small budget measured from now.
77            let kill_deadline = Instant::now() + REAP_GRACE;
78            let mut child = self.child.lock().await;
79            if let Err(error) = child.start_kill() {
80                kill_error = Some(error);
81            } else {
82                let _ = tokio::time::timeout_at(kill_deadline, child.wait()).await;
83            }
84        }
85        if let Some(stderr) = self.stderr.lock().await.take() {
86            // Likewise measured from now: `deadline` is already spent whenever
87            // the child needed killing, and draining a closed pipe is bounded.
88            finish_stderr(stderr, Instant::now() + REAP_GRACE).await;
89        }
90        wait_outcome?;
91        if let Some(error) = kill_error {
92            return Err(Error::Io(error));
93        }
94        protocol_outcome
95    }
96}
97
98/// An initialized `secretspec.provider/1` client together with the child
99/// process that owns its private transport.
100pub struct ProviderSession {
101    child: ChildSession,
102    capabilities: HashSet<String>,
103    metadata: Metadata,
104}
105
106/// Resolves provider authentication material requested over the private IPC
107/// session (0.21+).
108///
109/// Implementations must namespace the request by the already selected provider
110/// principal, must not log returned values, and should avoid consulting a
111/// general project provider that could recurse back into the endpoint being
112/// initialized.
113#[async_trait::async_trait]
114pub trait CredentialResponder: Send + Sync + 'static {
115    async fn credential(
116        &self,
117        params: callback::CredentialParams,
118    ) -> std::result::Result<callback::CredentialResult, RpcError>;
119}
120
121struct CredentialCallbacks {
122    responder: Arc<dyn CredentialResponder>,
123}
124
125#[async_trait::async_trait]
126impl CallbackHandler for CredentialCallbacks {
127    async fn call(
128        &self,
129        method: &str,
130        params: serde_json::Value,
131    ) -> std::result::Result<serde_json::Value, RpcError> {
132        if method != callback::method::CREDENTIAL {
133            return Err(RpcError::new(ErrorKind::MethodNotFound));
134        }
135        let params: callback::CredentialParams =
136            serde_json::from_value(params).map_err(|_| RpcError::new(ErrorKind::InvalidParams))?;
137        params
138            .validate()
139            .map_err(|_| RpcError::new(ErrorKind::InvalidParams))?;
140        let result = self.responder.credential(params).await?;
141        result
142            .validate()
143            .map_err(|_| RpcError::new(ErrorKind::InvalidParams))?;
144        serde_json::to_value(result).map_err(|_| RpcError::new(ErrorKind::Internal))
145    }
146}
147
148macro_rules! provider_calls {
149    ($(($name:ident, $method:ty)),+ $(,)?) => {
150        $(
151            pub async fn $name(
152                &self,
153                params: &<$method as provider_protocol::method::Method>::Params,
154                deadline_unix_ms: u64,
155            ) -> Result<<$method as provider_protocol::method::Method>::Result> {
156                self.execute::<$method>(params, deadline_unix_ms).await
157            }
158        )+
159    };
160}
161
162impl ProviderSession {
163    /// Launch and validate a provider endpoint. The returned session owns both
164    /// process and transport.
165    pub async fn launch(
166        options: LaunchOptions,
167        client: Product,
168        limits: Limits,
169        application: ProviderInitializeApplication,
170        startup_deadline_unix_ms: u64,
171    ) -> Result<Self> {
172        Self::launch_with_credential_broker(
173            options,
174            client,
175            limits,
176            application,
177            startup_deadline_unix_ms,
178            None,
179        )
180        .await
181    }
182
183    /// As [`Self::launch`], allowing the endpoint to request only the provider
184    /// credentials it actually needs during initialization or a provider
185    /// operation, including `provider.set` (0.21+). The responder supplies the
186    /// value through IPC; it does not require a terminal and may use a GUI,
187    /// credential store, or another caller-owned input mechanism.
188    pub async fn launch_with_credential_broker(
189        options: LaunchOptions,
190        client: Product,
191        limits: Limits,
192        application: ProviderInitializeApplication,
193        startup_deadline_unix_ms: u64,
194        responder: Option<Arc<dyn CredentialResponder>>,
195    ) -> Result<Self> {
196        application.validate()?;
197        let expected_scheme = application.scheme.clone();
198        let (client_methods, callbacks): (Vec<String>, Option<Arc<dyn CallbackHandler>>) =
199            match responder {
200                Some(responder) => (
201                    vec![callback::method::CREDENTIAL.to_string()],
202                    Some(Arc::new(CredentialCallbacks { responder })),
203                ),
204                None => (Vec::new(), None),
205            };
206        let initialize = InitializeParams {
207            protocol: PROVIDER_PROTOCOL.to_string(),
208            versions: vec![PROTOCOL_VERSION],
209            client,
210            limits,
211            client_methods,
212            application,
213        };
214        let (child, initialized) = spawn_with_callbacks::<_, ProviderInitializedApplication>(
215            options,
216            initialize,
217            startup_deadline_unix_ms,
218            callbacks,
219        )
220        .await?;
221        let validation = initialized
222            .application
223            .provider
224            .validate()
225            .and_then(|_| provider_protocol::validate_capabilities(&initialized.methods))
226            .and_then(|_| {
227                if initialized.application.provider.name == expected_scheme {
228                    Ok(())
229                } else {
230                    Err(Error::Protocol(
231                        "provider metadata name does not match its scheme",
232                    ))
233                }
234            });
235        if let Err(error) = validation {
236            let _ = child
237                .close(deadline_unix_ms_after(Duration::from_secs(1)))
238                .await;
239            return Err(error);
240        }
241        Ok(Self {
242            child,
243            capabilities: initialized.methods.into_iter().collect(),
244            metadata: initialized.application.provider,
245        })
246    }
247
248    pub fn raw(&self) -> &Client {
249        self.child.client()
250    }
251
252    pub async fn execute<M>(&self, params: &M::Params, deadline_unix_ms: u64) -> Result<M::Result>
253    where
254        M: provider_protocol::method::Method,
255    {
256        self.call(M::NAME, params, deadline_unix_ms).await
257    }
258
259    pub async fn call<P: Serialize, R: DeserializeOwned>(
260        &self,
261        method: &str,
262        params: &P,
263        deadline_unix_ms: u64,
264    ) -> Result<R> {
265        self.child
266            .client()
267            .call(method, params, deadline_unix_ms)
268            .await
269    }
270
271    provider_calls!(
272        (resolve_address, provider_protocol::method::ResolveAddress),
273        (get, provider_protocol::method::Get),
274        (get_many, provider_protocol::method::GetMany),
275        (exists, provider_protocol::method::Exists),
276        (set, provider_protocol::method::Set),
277        (set_expiring, provider_protocol::method::SetExpiring),
278        (delete, provider_protocol::method::Delete),
279        (clear, provider_protocol::method::Clear),
280        (
281            describe_write_target,
282            provider_protocol::method::DescribeWriteTarget
283        ),
284        (reflect, provider_protocol::method::Reflect),
285    );
286
287    pub async fn check_writable(
288        &self,
289        params: &provider_protocol::AddressParams,
290        deadline_unix_ms: u64,
291    ) -> Result<()> {
292        self.execute::<provider_protocol::method::CheckWritable>(params, deadline_unix_ms)
293            .await
294            .map(|_| ())
295    }
296
297    pub async fn check_deletable(
298        &self,
299        params: &provider_protocol::AddressParams,
300        deadline_unix_ms: u64,
301    ) -> Result<()> {
302        self.execute::<provider_protocol::method::CheckDeletable>(params, deadline_unix_ms)
303            .await
304            .map(|_| ())
305    }
306
307    pub fn capabilities(&self) -> &HashSet<String> {
308        &self.capabilities
309    }
310
311    pub fn supports(&self, method: &str) -> bool {
312        self.capabilities.contains(method)
313    }
314
315    pub fn metadata(&self) -> &Metadata {
316        &self.metadata
317    }
318
319    pub fn is_closed(&self) -> bool {
320        self.child.client().is_closed()
321    }
322
323    pub async fn close(&self, deadline_unix_ms: u64) -> Result<()> {
324        self.child.close(deadline_unix_ms).await
325    }
326}
327
328/// An initialized resolver session over a private authenticated stream (0.21+).
329///
330/// A launched session also owns its transport child. A connected session owns
331/// only the stream; closing it releases this session, not the hosting service.
332/// Reconnecting creates a fresh session and never replays an interrupted call.
333pub struct ResolverSession {
334    transport: ResolverTransport,
335    filesystem: FilesystemAccess,
336    capabilities: HashSet<String>,
337    initialized: ResolverInitializedApplication,
338}
339
340enum ResolverTransport {
341    Child(ChildSession),
342    Connected(Client),
343}
344
345impl ResolverTransport {
346    fn client(&self) -> &Client {
347        match self {
348            Self::Child(child) => child.client(),
349            Self::Connected(client) => client,
350        }
351    }
352
353    async fn close(&self, deadline_unix_ms: u64) -> Result<()> {
354        match self {
355            Self::Child(child) => child.close(deadline_unix_ms).await,
356            Self::Connected(client) => client.close(deadline_unix_ms).await,
357        }
358    }
359}
360
361/// Obtains one secret value from a person on the resolver's behalf (0.21+).
362///
363/// A resolver in stdio mode has no terminal: its stdin and stdout are the
364/// protocol. When a declaration says `prompt = true` and no value is stored,
365/// the process that can ask is the one that launched the resolver, so the
366/// resolver asks it. A session that installs no responder advertises nothing,
367/// and such a declaration fails immediately with `interaction_required` rather
368/// than waiting out a deadline.
369///
370/// The answer is a secret. Implementations must read it without echo, must not
371/// log it, and should hand it straight back.
372#[async_trait::async_trait]
373pub trait PromptResponder: Send + Sync + 'static {
374    async fn prompt(
375        &self,
376        params: callback::PromptParams,
377    ) -> std::result::Result<callback::PromptResult, RpcError>;
378}
379
380struct PromptCallbacks {
381    responder: Arc<dyn PromptResponder>,
382}
383
384#[async_trait::async_trait]
385impl CallbackHandler for PromptCallbacks {
386    async fn call(
387        &self,
388        method: &str,
389        params: serde_json::Value,
390    ) -> std::result::Result<serde_json::Value, RpcError> {
391        if method != callback::method::PROMPT {
392            return Err(RpcError::new(ErrorKind::MethodNotFound));
393        }
394        let params: callback::PromptParams =
395            serde_json::from_value(params).map_err(|_| RpcError::new(ErrorKind::InvalidParams))?;
396        params
397            .validate()
398            .map_err(|_| RpcError::new(ErrorKind::InvalidParams))?;
399        let result = self.responder.prompt(params).await?;
400        // Validated on the way out too: an answer this client would refuse to
401        // accept from a peer is one it must not send as a peer either.
402        result
403            .validate()
404            .map_err(|_| RpcError::new(ErrorKind::InvalidParams))?;
405        serde_json::to_value(result).map_err(|_| RpcError::new(ErrorKind::Internal))
406    }
407}
408
409impl ResolverSession {
410    pub async fn launch(
411        options: LaunchOptions,
412        client: Product,
413        limits: Limits,
414        application: ResolverInitializeApplication,
415        startup_deadline_unix_ms: u64,
416    ) -> Result<Self> {
417        Self::launch_with_prompt(
418            options,
419            client,
420            limits,
421            application,
422            startup_deadline_unix_ms,
423            None,
424        )
425        .await
426    }
427
428    /// As [`Self::launch`], letting the resolver ask this process for a value a
429    /// `prompt = true` declaration has no stored value for (0.21+).
430    pub async fn launch_with_prompt(
431        options: LaunchOptions,
432        client: Product,
433        limits: Limits,
434        application: ResolverInitializeApplication,
435        startup_deadline_unix_ms: u64,
436        responder: Option<Arc<dyn PromptResponder>>,
437    ) -> Result<Self> {
438        application.validate_for_connection()?;
439        let (client_methods, callbacks): (Vec<String>, Option<Arc<dyn CallbackHandler>>) =
440            match responder {
441                Some(responder) => (
442                    vec![callback::method::PROMPT.to_string()],
443                    Some(Arc::new(PromptCallbacks { responder })),
444                ),
445                None => (Vec::new(), None),
446            };
447        let initialize = InitializeParams {
448            protocol: RESOLVER_PROTOCOL.to_string(),
449            versions: vec![PROTOCOL_VERSION],
450            client,
451            limits,
452            client_methods,
453            application,
454        };
455        let (child, initialized) = spawn_with_callbacks::<_, ResolverInitializedApplication>(
456            options,
457            initialize,
458            startup_deadline_unix_ms,
459            callbacks,
460        )
461        .await?;
462        Self::from_transport(
463            ResolverTransport::Child(child),
464            initialized,
465            FilesystemAccess::Shared,
466        )
467        .await
468    }
469
470    /// Connect an already authenticated and authorized stream (0.21+).
471    ///
472    /// The caller must authenticate the endpoint and protect both directions
473    /// before calling this method. The server must authorize initialization
474    /// against the authenticated principal. Manifest paths and inline base_dir
475    /// always name locations on the resolver machine.
476    pub async fn connect<R, W>(
477        reader: R,
478        writer: W,
479        client: Product,
480        limits: Limits,
481        application: ResolverInitializeApplication,
482        filesystem: FilesystemAccess,
483        startup_deadline_unix_ms: u64,
484    ) -> Result<Self>
485    where
486        R: AsyncRead + Unpin + Send + 'static,
487        W: AsyncWrite + Unpin + Send + 'static,
488    {
489        Self::connect_with_prompt(
490            reader,
491            writer,
492            client,
493            limits,
494            application,
495            filesystem,
496            startup_deadline_unix_ms,
497            None,
498        )
499        .await
500    }
501
502    /// As [`Self::connect`], supporting prompts on the calling device (0.21+).
503    #[allow(clippy::too_many_arguments)]
504    pub async fn connect_with_prompt<R, W>(
505        reader: R,
506        writer: W,
507        client: Product,
508        limits: Limits,
509        application: ResolverInitializeApplication,
510        filesystem: FilesystemAccess,
511        startup_deadline_unix_ms: u64,
512        responder: Option<Arc<dyn PromptResponder>>,
513    ) -> Result<Self>
514    where
515        R: AsyncRead + Unpin + Send + 'static,
516        W: AsyncWrite + Unpin + Send + 'static,
517    {
518        application.validate_for_connection()?;
519        let (client_methods, callbacks): (Vec<String>, Option<Arc<dyn CallbackHandler>>) =
520            match responder {
521                Some(responder) => (
522                    vec![callback::method::PROMPT.to_string()],
523                    Some(Arc::new(PromptCallbacks { responder })),
524                ),
525                None => (Vec::new(), None),
526            };
527        let initialize = InitializeParams {
528            protocol: RESOLVER_PROTOCOL.to_string(),
529            versions: vec![PROTOCOL_VERSION],
530            client,
531            limits,
532            client_methods,
533            application,
534        };
535        let (client, initialized) = Client::connect_with_callbacks(
536            reader,
537            writer,
538            initialize,
539            startup_deadline_unix_ms,
540            callbacks,
541        )
542        .await?;
543        Self::from_transport(
544            ResolverTransport::Connected(client),
545            initialized,
546            filesystem,
547        )
548        .await
549    }
550
551    /// Launch a resolver over SSH with preconfigured authentication (0.21+).
552    /// Calling this again starts a fresh session; it never resumes old requests.
553    pub async fn launch_ssh(
554        options: SshOptions,
555        client: Product,
556        limits: Limits,
557        application: ResolverInitializeApplication,
558        startup_deadline_unix_ms: u64,
559    ) -> Result<Self> {
560        Self::launch_ssh_with_prompt(
561            options,
562            client,
563            limits,
564            application,
565            startup_deadline_unix_ms,
566            None,
567        )
568        .await
569    }
570
571    /// As [`Self::launch_ssh`], supporting resolver prompts locally (0.21+).
572    pub async fn launch_ssh_with_prompt(
573        options: SshOptions,
574        client: Product,
575        limits: Limits,
576        application: ResolverInitializeApplication,
577        startup_deadline_unix_ms: u64,
578        responder: Option<Arc<dyn PromptResponder>>,
579    ) -> Result<Self> {
580        let mut session = Self::launch_with_prompt(
581            options.launch_options()?,
582            client,
583            limits,
584            application,
585            startup_deadline_unix_ms,
586            responder,
587        )
588        .await?;
589        session.filesystem = options.filesystem;
590        Ok(session)
591    }
592
593    async fn from_transport(
594        transport: ResolverTransport,
595        initialized: InitializeResult<ResolverInitializedApplication>,
596        filesystem: FilesystemAccess,
597    ) -> Result<Self> {
598        if let Err(error) = initialized
599            .application
600            .validate()
601            .and_then(|_| resolver_protocol::validate_capabilities(&initialized.methods))
602        {
603            let _ = transport
604                .close(deadline_unix_ms_after(Duration::from_secs(1)))
605                .await;
606            return Err(error);
607        }
608        Ok(Self {
609            transport,
610            filesystem,
611            capabilities: initialized.methods.into_iter().collect(),
612            initialized: initialized.application,
613        })
614    }
615
616    /// Raw protocol access. The caller must enforce filesystem policy itself;
617    /// prefer the typed methods, which reject remote path resolution (0.21+).
618    pub fn raw(&self) -> &Client {
619        self.transport.client()
620    }
621
622    pub async fn get(
623        &self,
624        params: &resolver_protocol::GetParams,
625        deadline_unix_ms: u64,
626    ) -> Result<resolver_protocol::GetResult> {
627        let params = self.filesystem.prepare_get(params)?;
628        let result = self
629            .transport
630            .client()
631            .call(
632                resolver_protocol::method::GET,
633                params.as_ref(),
634                deadline_unix_ms,
635            )
636            .await?;
637        if !self.filesystem.accepts(&result) {
638            // A nonconforming peer returned a path despite a Value request.
639            // Closing the session releases any path leases without exposing it.
640            let _ = self.close(deadline_unix_ms).await;
641            return Err(Error::Protocol(
642                "resolver returned a path on a remote filesystem",
643            ));
644        }
645        Ok(result)
646    }
647
648    pub async fn release(
649        &self,
650        params: &resolver_protocol::ReleaseParams,
651        deadline_unix_ms: u64,
652    ) -> Result<resolver_protocol::ReleaseResult> {
653        self.transport
654            .client()
655            .call(resolver_protocol::method::RELEASE, params, deadline_unix_ms)
656            .await
657    }
658
659    /// Store one declared name (0.21+). Only endpoints that advertise
660    /// `resolver.set` accept it, so check [`Self::supports`] first when the
661    /// caller can explain a read-only endpoint better than the wire error does.
662    pub async fn set(
663        &self,
664        params: &resolver_protocol::SetParams,
665        deadline_unix_ms: u64,
666    ) -> Result<resolver_protocol::SetResult> {
667        self.transport
668            .client()
669            .call(resolver_protocol::method::SET, params, deadline_unix_ms)
670            .await
671    }
672
673    /// Remove one declared name's stored value (0.21+), advertised as
674    /// `resolver.delete` under the same rule as [`Self::set`].
675    pub async fn delete(
676        &self,
677        params: &resolver_protocol::DeleteParams,
678        deadline_unix_ms: u64,
679    ) -> Result<resolver_protocol::DeleteResult> {
680        self.transport
681            .client()
682            .call(resolver_protocol::method::DELETE, params, deadline_unix_ms)
683            .await
684    }
685
686    pub fn capabilities(&self) -> &HashSet<String> {
687        &self.capabilities
688    }
689
690    /// Whether the endpoint advertised one method, such as
691    /// [`resolver_protocol::method::SET`].
692    pub fn supports(&self, method: &str) -> bool {
693        self.capabilities.contains(method)
694    }
695
696    pub fn initialized(&self) -> &ResolverInitializedApplication {
697        &self.initialized
698    }
699
700    pub fn is_closed(&self) -> bool {
701        self.transport.client().is_closed()
702    }
703
704    pub async fn close(&self, deadline_unix_ms: u64) -> Result<()> {
705        self.transport.close(deadline_unix_ms).await
706    }
707}
708
709impl Drop for ChildSession {
710    fn drop(&mut self) {
711        // Emergency best effort only; the explicit async close path reaps and
712        // joins every worker. Never block a foreign/runtime destructor. When
713        // the lock is contended, the monitor drops the last child handle once
714        // cancelled, and `kill_on_drop` kills the child then.
715        self.monitor_cancel.cancel();
716        if let Ok(mut child) = self.child.try_lock() {
717            let _ = child.start_kill();
718        }
719    }
720}
721
722pub async fn spawn<A, B>(
723    options: LaunchOptions,
724    initialize: InitializeParams<A>,
725    startup_deadline_unix_ms: u64,
726) -> Result<(ChildSession, InitializeResult<B>)>
727where
728    A: Serialize,
729    B: DeserializeOwned,
730{
731    spawn_with_callbacks(options, initialize, startup_deadline_unix_ms, None).await
732}
733
734/// As [`spawn`], installing the handler for the callbacks this client
735/// advertised in `client_methods` (0.21+).
736pub async fn spawn_with_callbacks<A, B>(
737    options: LaunchOptions,
738    initialize: InitializeParams<A>,
739    startup_deadline_unix_ms: u64,
740    callbacks: Option<Arc<dyn CallbackHandler>>,
741) -> Result<(ChildSession, InitializeResult<B>)>
742where
743    A: Serialize,
744    B: DeserializeOwned,
745{
746    options.validate()?;
747    let mut command = Command::new(&options.executable);
748    command
749        .args(&options.arguments)
750        .stdin(Stdio::piped())
751        .stdout(Stdio::piped())
752        .stderr(Stdio::piped())
753        // The last owner of the child kills it: a launch future dropped
754        // mid-initialization, an early error return, or a session dropped
755        // while the monitor held the lock. A reaped child is not signalled.
756        .kill_on_drop(true);
757    match &options.environment {
758        Environment::Inherit(overrides) => {
759            command.envs(overrides);
760        }
761        Environment::Replace(environment) => {
762            command.env_clear().envs(environment);
763        }
764    }
765
766    let mut child = command.spawn()?;
767    let stdin = child
768        .stdin
769        .take()
770        .ok_or(Error::Protocol("child stdin was not piped"))?;
771    let stdout = child
772        .stdout
773        .take()
774        .ok_or(Error::Protocol("child stdout was not piped"))?;
775    let mut stderr = child
776        .stderr
777        .take()
778        .ok_or(Error::Protocol("child stderr was not piped"))?;
779
780    let stderr_limit = options.max_stderr_bytes;
781    let stderr_task = tokio::spawn(async move {
782        // Allocated once at the bound: growing would free unwiped copies.
783        let mut retained = Zeroizing::new(Vec::with_capacity(stderr_limit));
784        let mut buffer = Zeroizing::new(vec![0_u8; 4096]);
785        loop {
786            let read: usize = stderr.read(&mut buffer).await.unwrap_or_default();
787            if read == 0 {
788                break;
789            }
790            let available = stderr_limit.saturating_sub(retained.len());
791            retained.extend_from_slice(&buffer[..read.min(available)]);
792        }
793        retained
794    });
795
796    let child = Arc::new(Mutex::new(child));
797    let connect = Client::connect_with_callbacks(
798        stdout,
799        stdin,
800        initialize,
801        startup_deadline_unix_ms,
802        callbacks,
803    )
804    .await;
805    let (client, initialized) = match connect {
806        Ok(value) => value,
807        Err(error) => {
808            let mut child = child.lock().await;
809            let _ = child.start_kill();
810            // Initialization commonly fails because its deadline has already
811            // elapsed. Killing and reaping are bounded cleanup from this
812            // point, so they need a fresh budget rather than that spent
813            // startup deadline.
814            let _ = tokio::time::timeout(REAP_GRACE, child.wait()).await;
815            drop(child);
816            finish_stderr(stderr_task, Instant::now() + REAP_GRACE).await;
817            return Err(error);
818        }
819    };
820
821    let monitor_cancel = CancellationToken::new();
822    let monitor_child = child.clone();
823    let monitor_client = client.clone();
824    let monitor_stop = monitor_cancel.clone();
825    let monitor = tokio::spawn(async move {
826        loop {
827            tokio::select! {
828                _ = monitor_stop.cancelled() => break,
829                _ = tokio::time::sleep(Duration::from_millis(25)) => {
830                    let exited = monitor_child
831                        .lock()
832                        .await
833                        .try_wait()
834                        .ok()
835                        .flatten()
836                        .is_some();
837                    if exited {
838                        monitor_client
839                            .abandon_after_process_exit(REAP_GRACE)
840                            .await;
841                        break;
842                    }
843                }
844            }
845        }
846    });
847
848    Ok((
849        ChildSession {
850            client,
851            child,
852            monitor_cancel,
853            monitor: Mutex::new(Some(monitor)),
854            stderr: Mutex::new(Some(stderr_task)),
855            closed: AtomicBool::new(false),
856        },
857        initialized,
858    ))
859}
860
861async fn wait_until(child: &Arc<Mutex<Child>>, deadline: Instant) -> Result<bool> {
862    loop {
863        if child.lock().await.try_wait()?.is_some() {
864            return Ok(true);
865        }
866        if Instant::now() >= deadline {
867            return Ok(false);
868        }
869        tokio::time::sleep(Duration::from_millis(10)).await;
870    }
871}
872
873async fn finish_stderr(mut task: JoinHandle<Zeroizing<Vec<u8>>>, deadline: Instant) {
874    if tokio::time::timeout_at(deadline, &mut task).await.is_err() {
875        task.abort();
876        let _ = task.await;
877    }
878}
879
880#[cfg(test)]
881mod tests {
882    use super::*;
883    use crate::protocol::InitializeResult;
884    use crate::server::{ApplicationHandler, RequestContext, RpcResult, ServerConfig, serve};
885    use async_trait::async_trait;
886    use serde_json::{Value, json};
887    use tokio::sync::Notify;
888
889    struct GatedShutdown {
890        entered: Arc<Notify>,
891        release: Arc<Notify>,
892    }
893
894    #[async_trait]
895    impl ApplicationHandler for GatedShutdown {
896        fn protocol(&self) -> &'static str {
897            RESOLVER_PROTOCOL
898        }
899
900        fn capabilities(&self) -> Vec<String> {
901            vec!["resolver.get".to_string()]
902        }
903
904        async fn initialize(
905            &self,
906            _context: &RequestContext,
907            _application: Value,
908        ) -> RpcResult<Value> {
909            Ok(json!({}))
910        }
911
912        async fn call(
913            &self,
914            _context: RequestContext,
915            _method: &str,
916            _params: Value,
917        ) -> RpcResult<Value> {
918            Ok(json!({}))
919        }
920
921        async fn shutdown(&self) {
922            self.entered.notify_one();
923            self.release.notified().await;
924        }
925    }
926
927    /// A caller that abandons a launch (a `select!` or an outer timeout) must
928    /// not leave the endpoint running.
929    #[cfg(target_os = "linux")]
930    #[tokio::test]
931    async fn a_dropped_launch_kills_its_child() {
932        let directory = tempfile::tempdir().unwrap();
933        let pid_file = directory.path().join("pid");
934        let options = LaunchOptions {
935            executable: "/bin/sh".into(),
936            arguments: vec![
937                "-c".into(),
938                // Never answers initialize, so the launch stays pending.
939                "echo $$ > \"$0\"; exec cat > /dev/null".into(),
940                pid_file.clone().into(),
941            ],
942            environment: Environment::Inherit(Default::default()),
943            allow_path_discovery: false,
944            max_stderr_bytes: 4096,
945        };
946        let initialize = InitializeParams {
947            protocol: RESOLVER_PROTOCOL.to_string(),
948            versions: vec![PROTOCOL_VERSION],
949            client: Product {
950                name: "drop-test".to_string(),
951                version: "1".to_string(),
952            },
953            limits: Limits {
954                max_frame_bytes: 32 * 1024,
955                max_in_flight: 4,
956            },
957            client_methods: Vec::new(),
958            application: json!({}),
959        };
960        let launch = spawn::<Value, Value>(
961            options,
962            initialize,
963            deadline_unix_ms_after(Duration::from_secs(300)),
964        );
965        // Boxed so `drop` below destroys the future itself, not a pinned borrow.
966        let mut launch = Box::pin(launch);
967        let started = async {
968            loop {
969                if let Ok(pid) = std::fs::read_to_string(&pid_file)
970                    && let Some(pid) = pid.strip_suffix('\n')
971                {
972                    return pid.to_string();
973                }
974                tokio::task::yield_now().await;
975            }
976        };
977        let pid = tokio::select! {
978            biased;
979            _ = &mut launch => panic!("an endpoint that never answers cannot initialize"),
980            pid = started => pid,
981        };
982        drop(launch);
983
984        // SIGKILL leaves the process a zombie until the runtime reaps it, so
985        // either state proves it no longer runs.
986        let stat = format!("/proc/{pid}/stat");
987        loop {
988            match std::fs::read_to_string(&stat) {
989                Err(_) => break,
990                Ok(stat)
991                    if stat
992                        .rsplit_once(')')
993                        .unwrap()
994                        .1
995                        .trim_start()
996                        .starts_with('Z') =>
997                {
998                    break;
999                }
1000                Ok(_) => tokio::task::yield_now().await,
1001            }
1002        }
1003    }
1004
1005    #[tokio::test]
1006    async fn stderr_cleanup_is_bounded() {
1007        let task = tokio::spawn(async {
1008            std::future::pending::<()>().await;
1009            Zeroizing::new(Vec::new())
1010        });
1011        tokio::time::timeout(
1012            Duration::from_millis(250),
1013            finish_stderr(task, Instant::now() + Duration::from_millis(20)),
1014        )
1015        .await
1016        .expect("stderr cleanup exceeded its deadline");
1017    }
1018
1019    /// A process watcher can observe exit immediately after the endpoint
1020    /// commits its shutdown response, before the reader consumes that buffered
1021    /// frame. Start the simulated watcher while shutdown is gated (an even
1022    /// stricter ordering), then repeat enough times to exercise scheduler
1023    /// choices.
1024    #[tokio::test]
1025    async fn process_exit_waits_for_a_buffered_shutdown_response() {
1026        for _ in 0..32 {
1027            let entered = Arc::new(Notify::new());
1028            let release = Arc::new(Notify::new());
1029            let handler = Arc::new(GatedShutdown {
1030                entered: entered.clone(),
1031                release: release.clone(),
1032            });
1033            let (client_io, server_io) = tokio::io::duplex(64 * 1024);
1034            let (client_read, client_write) = tokio::io::split(client_io);
1035            let (server_read, server_write) = tokio::io::split(server_io);
1036            let server = tokio::spawn(serve(
1037                server_read,
1038                server_write,
1039                handler,
1040                ServerConfig::default(),
1041            ));
1042            let initialize = InitializeParams {
1043                protocol: RESOLVER_PROTOCOL.to_string(),
1044                versions: vec![PROTOCOL_VERSION],
1045                client: Product {
1046                    name: "exit-race-test".to_string(),
1047                    version: "1".to_string(),
1048                },
1049                limits: Limits {
1050                    max_frame_bytes: 32 * 1024,
1051                    max_in_flight: 4,
1052                },
1053                client_methods: Vec::new(),
1054                application: json!({}),
1055            };
1056            let (client, _): (Client, InitializeResult<Value>) = Client::connect(
1057                client_read,
1058                client_write,
1059                initialize,
1060                deadline_unix_ms_after(Duration::from_secs(2)),
1061            )
1062            .await
1063            .unwrap();
1064
1065            let close_client = client.clone();
1066            let close = tokio::spawn(async move {
1067                close_client
1068                    .close(deadline_unix_ms_after(Duration::from_secs(2)))
1069                    .await
1070            });
1071            entered.notified().await;
1072            let monitor_client = client.clone();
1073            let monitor = tokio::spawn(async move {
1074                monitor_client
1075                    .abandon_after_process_exit(Duration::from_secs(1))
1076                    .await;
1077            });
1078            release.notify_one();
1079
1080            close.await.unwrap().unwrap();
1081            monitor.await.unwrap();
1082            server.await.unwrap().unwrap();
1083        }
1084    }
1085}