Skip to main content

hyperactor_remote/proc_spawner/
unix.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Unix process backed proc spawning.
10//!
11//! [`UnixProcSpawner`] launches one OS child process per spawned proc and
12//! supervises it through a per-proc `UnixProcWorker`. Inside the child, a
13//! proc-local [`SpawnActor`] endpoint hosts the spawned actors. The child joins
14//! its spawner over a one-shot token; the worker then links the proc and relays
15//! its supervision to the caller.
16//!
17//! The actors (boxes) and the messages they exchange, grouped by the proc each
18//! runs on. `UnixProcSpawner` and its per-proc `UnixProcWorker`s share the
19//! spawner proc; the caller's `Supervisor` lives on the caller proc (possibly
20//! remote); each spawned `ActorSpawner` runs in its own OS process, a child
21//! proc of the spawner proc. Once linked, the caller spawns actors directly on
22//! the child through its `ActorSpawner` endpoint (the `SpawnActor` arrow) — the
23//! spawner's job is only to create and supervise the proc.
24//!
25//! ```text
26//!   ┌─────────────┐   SpawnProc        ┌─────────────────┐
27//!   │             │ ─────────────────▶ │ UnixProcSpawner │ ┐
28//!   │             │                    └─────────────────┘ │
29//!   │             │   WorkerCommand             │ spawns    │ spawner proc
30//!   │  Supervisor │ ─────────────────▶ ┌────────▼────────┐ │
31//!   │             │ ◀───────────────── │  UnixProcWorker │ ┘ ◀─ UnixProcExited (reaper)
32//!   │             │   SupervisorEvent  └─────────────────┘   ◀─ KillProc (self)
33//!   │             │                       │          ▲
34//!   │             │              StopProc │          │ token::Joined<UnixProcJoin>
35//!   │             │                       ▼          │ ActorSupervisionEvent
36//!   │             │   SpawnActor       ┌─────────────────┐ ┐ child proc
37//!   │             │ ─────────────────▶ │   ActorSpawner  │ ┘ (child of the
38//!   └─────────────┘   (future)         └─────────────────┘   spawner proc)
39//!     caller proc
40//! ```
41//!
42//! - `SpawnProc`: the caller asks the spawner to create a proc.
43//! - `WorkerCommand`: the caller drives a linked proc (stop, unlink).
44//! - `SupervisorEvent`: the worker reports lifecycle to the caller's supervisor.
45//! - `token::Joined<UnixProcJoin>`: the child joins, handing back its gateway
46//!   address, [`SpawnActor`] endpoint, and control port.
47//! - `StopProc`: the worker asks the child to drain and stop gracefully.
48//! - `ActorSupervisionEvent`: the child forwards a top-level failure to the worker.
49//! - `UnixProcExited`: the OS-process reaper task reports the child's exit status.
50//! - `KillProc`: a worker self-message that hard-kills the child when a graceful
51//!   stop misses the grace period.
52//! - `SpawnActor`: after linking, the caller spawns actors directly on the child
53//!   proc through its `ActorSpawner` endpoint.
54
55use std::collections::HashMap;
56use std::ffi::OsString;
57use std::fmt;
58use std::path::PathBuf;
59use std::process::ExitStatus;
60use std::str::FromStr;
61use std::time::Duration;
62
63use async_trait::async_trait;
64use hyperactor::Actor;
65use hyperactor::ActorHandle;
66use hyperactor::ActorRef;
67use hyperactor::AnyActorHandle;
68use hyperactor::Context;
69use hyperactor::Endpoint;
70use hyperactor::Gateway;
71use hyperactor::Handler;
72use hyperactor::Instance;
73use hyperactor::Location;
74use hyperactor::PortRef;
75use hyperactor::Proc;
76use hyperactor::ProcAddr;
77use hyperactor::Uid;
78use hyperactor::actor::ActorStatus;
79use hyperactor::actor::StopMode;
80use hyperactor::channel::ChannelAddr;
81use hyperactor::channel::ChannelTransport;
82use hyperactor::context;
83use hyperactor::context::Actor as _;
84use hyperactor::gateway::GatewayServeHandle;
85use hyperactor::gateway::PeerAttachGuard;
86use hyperactor::mailbox::IntoBoxedMailboxSender as _;
87use hyperactor::mailbox::MailboxClient;
88use hyperactor::supervision::ActorSupervisionEvent;
89use nix::errno::Errno;
90use nix::sys::signal;
91use nix::sys::signal::Signal;
92use nix::unistd::Pid;
93use serde::Deserialize;
94use serde::Serialize;
95use tokio::process::Child;
96use tokio::process::Command;
97use tokio::sync::oneshot;
98use typeuri::Named;
99
100use super::SpawnProc;
101use super::actor_spawner_ref;
102use super::actor_spawner_uid;
103use super::spawned_proc_addr_for_spawner_addr;
104use crate::ActorSpawner;
105use crate::OrphanPolicy;
106use crate::RemoteActorDisposition;
107use crate::Supervise;
108use crate::SupervisionOptions;
109use crate::SupervisorEvent;
110use crate::TokenOptions;
111use crate::TokenPolicy;
112use crate::WorkerCommand;
113use crate::actor_spawner::SpawnActor;
114use crate::proto::SpawnProc as SpawnProcMessage;
115use crate::token;
116
117/// Environment variable containing the one-shot token a child proc uses to boot and join its spawner.
118pub const UNIX_PROC_TOKEN_ENV: &str = "HYPERACTOR_REMOTE_PROC_TOKEN";
119
120/// Token used by a Unix child proc to boot and report readiness to its spawner.
121#[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq, Eq)]
122pub struct UnixProcToken {
123    proc_addr: ProcAddr,
124    /// Port on the spawner-side worker that coordinates this proc's
125    /// top-level supervision events. The child installs it as the proc's
126    /// supervision coordinator so failures surface to the worker at once.
127    supervisor_events: PortRef<ActorSupervisionEvent>,
128    rendezvous: token::Token<(), UnixProcJoin>,
129}
130
131impl UnixProcToken {
132    /// Create a Unix proc boot token.
133    pub fn new(
134        proc_addr: ProcAddr,
135        supervisor_events: PortRef<ActorSupervisionEvent>,
136        rendezvous: token::Token<(), UnixProcJoin>,
137    ) -> Self {
138        Self {
139            proc_addr,
140            supervisor_events,
141            rendezvous,
142        }
143    }
144
145    /// The proc address that the child should use.
146    pub fn proc_addr(&self) -> &ProcAddr {
147        &self.proc_addr
148    }
149
150    /// The worker port that coordinates this proc's supervision events.
151    pub fn supervisor_events(&self) -> &PortRef<ActorSupervisionEvent> {
152        &self.supervisor_events
153    }
154
155    fn join(
156        &self,
157        cx: &impl context::Actor,
158        joiner: UnixProcJoin,
159        result: PortRef<token::JoinResult<()>>,
160    ) -> anyhow::Result<()> {
161        self.rendezvous.join(cx, joiner, result)
162    }
163}
164
165impl fmt::Display for UnixProcToken {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        let token = serde_json::to_string(self).map_err(|_| fmt::Error)?;
168        f.write_str(&token)
169    }
170}
171
172impl FromStr for UnixProcToken {
173    type Err = anyhow::Error;
174
175    fn from_str(token: &str) -> Result<Self, Self::Err> {
176        Ok(serde_json::from_str(token)?)
177    }
178}
179
180/// Graceful stop request sent by the spawner to a child proc's control
181/// endpoint. The child drains and stops its actor-spawn endpoint, which
182/// tears the proc down cleanly so the OS process exits with a normal status.
183#[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq, Eq)]
184pub struct StopProc {
185    mode: StopMode,
186    reason: String,
187}
188wirevalue::register_type!(StopProc);
189
190/// Information exchanged with a Unix child proc when it joins its spawner.
191#[derive(Clone, Debug, Serialize, Deserialize, Named, PartialEq, Eq)]
192pub struct UnixProcJoin {
193    /// Address where the child proc accepts gateway traffic.
194    pub gateway_addr: Location,
195    /// The child proc's [`SpawnActor`] endpoint.
196    pub actor_spawner: ActorRef<SpawnActor>,
197    /// Endpoint the spawner posts a graceful [`StopProc`] to.
198    pub control: PortRef<StopProc>,
199}
200wirevalue::register_type!(UnixProcJoin);
201
202/// Command used by [`UnixProcSpawner`] to launch child procs.
203#[derive(Clone, Debug)]
204pub struct UnixProcCommand {
205    program: PathBuf,
206    args: Vec<OsString>,
207    env: Vec<(OsString, OsString)>,
208}
209
210impl UnixProcCommand {
211    /// Create a command from an executable path.
212    pub fn new(program: impl Into<PathBuf>) -> Self {
213        Self {
214            program: program.into(),
215            args: Vec::new(),
216            env: Vec::new(),
217        }
218    }
219
220    /// Add one command-line argument.
221    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
222        self.args.push(arg.into());
223        self
224    }
225
226    /// Add command-line arguments.
227    pub fn args<I, S>(mut self, args: I) -> Self
228    where
229        I: IntoIterator<Item = S>,
230        S: Into<OsString>,
231    {
232        self.args.extend(args.into_iter().map(Into::into));
233        self
234    }
235
236    /// Add one environment variable.
237    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
238        self.env.push((key.into(), value.into()));
239        self
240    }
241
242    /// Spawn the child OS process, passing `token` to it through the
243    /// environment. The child runs in its own process group so a later stop can
244    /// signal the whole proc tree (see [`signal_proc_group`]), and is killed if
245    /// the returned handle is dropped.
246    fn spawn(&self, token: &UnixProcToken) -> std::io::Result<Child> {
247        let mut command = Command::new(&self.program);
248        command.args(&self.args);
249        for (key, value) in &self.env {
250            command.env(key, value);
251        }
252        command.env(UNIX_PROC_TOKEN_ENV, token.to_string());
253        command.kill_on_drop(true);
254        // Put the child in its own process group so a stop signals the whole
255        // proc tree via the negative pid, mirroring the host bootstrap launcher.
256        // SAFETY: runs in the forked child before exec; only calls the
257        // async-signal-safe `setpgid` with constant arguments.
258        unsafe {
259            command.pre_exec(|| {
260                if libc::setpgid(0, 0) != 0 {
261                    return Err(std::io::Error::last_os_error());
262                }
263                Ok(())
264            });
265        }
266        command.spawn()
267    }
268}
269
270/// Unix process backed implementation of [`SpawnProc`].
271///
272/// `UnixProcSpawner` starts one OS child process per spawned proc. The child
273/// process calls [`UnixProc::boot_from_env`], starts a proc-local [`SpawnActor`]
274/// endpoint, serves its gateway on a Unix domain socket, and joins the one-shot
275/// token supplied by the spawner. The spawner links the proc only after that
276/// join, and the child process exits when the actor-spawn endpoint exits.
277#[derive(Debug)]
278#[hyperactor::export(SpawnProcMessage)]
279pub struct UnixProcSpawner {
280    command: UnixProcCommand,
281    procs: HashMap<Uid, ActorHandle<UnixProcWorker>>,
282}
283
284impl UnixProcSpawner {
285    /// Create a Unix proc spawner that launches `program` for every child proc.
286    pub fn new(program: impl Into<PathBuf>) -> Self {
287        Self::new_with_command(UnixProcCommand::new(program))
288    }
289
290    /// Create a Unix proc spawner with a pre-built child command.
291    pub fn new_with_command(command: UnixProcCommand) -> Self {
292        Self {
293            command,
294            procs: HashMap::new(),
295        }
296    }
297}
298
299#[async_trait]
300impl Actor for UnixProcSpawner {
301    async fn handle_supervision_event(
302        &mut self,
303        _this: &Instance<Self>,
304        event: &ActorSupervisionEvent,
305    ) -> anyhow::Result<bool> {
306        let exited_uid = self.procs.iter().find_map(|(uid, worker)| {
307            (worker.actor_addr() == &event.actor_id).then(|| uid.clone())
308        });
309        if let Some(uid) = exited_uid {
310            self.procs.remove(&uid);
311        }
312        Ok(!event.is_error())
313    }
314
315    async fn handle_stop(
316        &mut self,
317        this: &Instance<Self>,
318        mode: StopMode,
319        reason: &str,
320    ) -> anyhow::Result<()> {
321        // The caller-side supervision tree owns each spawned actor-spawn endpoint.
322        // `UnixProcSpawner` also owns the OS child processes, so spawner shutdown must
323        // stop every worker even if no individual caller has requested it.
324        for worker in self.procs.values() {
325            let _ = match mode {
326                StopMode::Stop => worker.stop(reason),
327                StopMode::DrainAndStop => worker.drain_and_stop(reason),
328            };
329        }
330        this.close();
331        match mode {
332            StopMode::Stop => this.exit(reason)?,
333            StopMode::DrainAndStop => this.exit_after_drain(reason)?,
334        }
335        Ok(())
336    }
337}
338
339#[async_trait]
340impl Handler<SpawnProcMessage> for UnixProcSpawner {
341    async fn handle(
342        &mut self,
343        cx: &Context<Self>,
344        message: SpawnProcMessage,
345    ) -> anyhow::Result<()> {
346        if !message.uid.is_instance() {
347            reject_supervise(cx, &message.supervise, "spawned procs cannot be singletons");
348            return Ok(());
349        }
350        let existing_is_active = self.procs.get(&message.uid).is_some_and(|worker| {
351            let status = worker.status();
352            !status.borrow().is_terminal()
353        });
354        if existing_is_active {
355            reject_supervise(
356                cx,
357                &message.supervise,
358                format!("proc {} already exists", message.uid),
359            );
360            return Ok(());
361        }
362        self.procs.remove(&message.uid);
363
364        let spawner_addr = cx.self_addr().proc_addr();
365        let proc_addr = spawned_proc_addr_for_spawner_addr(&spawner_addr, &message.uid);
366        let actor_spawner = actor_spawner_ref(&proc_addr);
367        let worker = cx.spawn(UnixProcWorker::new(
368            message.uid.clone(),
369            proc_addr,
370            actor_spawner,
371            self.command.clone(),
372            message.supervise,
373        ));
374        self.procs.insert(message.uid, worker);
375        Ok(())
376    }
377}
378
379hyperactor::assert_behaves!(UnixProcSpawner as SpawnProc);
380
381/// Helper for Unix child processes launched by [`UnixProcSpawner`].
382pub struct UnixProc;
383
384impl UnixProc {
385    /// Boot a Unix child proc from the environment set by [`UnixProcSpawner`].
386    pub async fn boot_from_env() -> anyhow::Result<ActorStatus> {
387        let token = parse_env::<UnixProcToken>(UNIX_PROC_TOKEN_ENV)?;
388        Self::boot(token).await
389    }
390
391    /// Boot a Unix child proc and join its spawner.
392    pub async fn boot(token: UnixProcToken) -> anyhow::Result<ActorStatus> {
393        // If the spawner dies, the kernel should take this proc down with it.
394        let _ = install_pdeathsig_kill();
395        let proc_addr = token.proc_addr().clone();
396        let advertised_location = proc_addr.location().clone();
397        Gateway::global().set_default_location(advertised_location.clone());
398        let proc = Proc::builder().proc_id(proc_addr.id().clone()).build()?;
399        // Route this proc's top-level supervision events to the spawner-side
400        // worker so a failure here surfaces immediately with full detail,
401        // rather than only as a process exit observed by `waitpid`.
402        proc.set_supervision_coordinator(token.supervisor_events().clone())?;
403        let actor_spawner_handle = proc.spawn_with_uid(actor_spawner_uid(), ActorSpawner)?;
404        let actor_spawner = actor_spawner_handle.bind::<SpawnActor>();
405
406        let (mut serve, gateway_addr) = Self::serve_own_socket(&proc)?;
407        let client = proc.client("unix-proc-bootstrap");
408        let (result, mut result_rx) = client.open_port::<token::JoinResult<()>>();
409        let (control, mut control_rx) = client.open_port::<StopProc>();
410        // `serve_own_socket` left the gateway's default location at the
411        // bound socket; restore the advertised location.
412        proc.set_default_location(advertised_location);
413        token.join(
414            &client,
415            UnixProcJoin {
416                gateway_addr,
417                actor_spawner,
418                control: control.bind(),
419            },
420            result.bind(),
421        )?;
422        match result_rx.recv().await? {
423            token::JoinResult::Joined { peer: _ } => {}
424            token::JoinResult::Rejected { reason } => {
425                anyhow::bail!("unix proc token join rejected: {}", reason);
426            }
427        }
428
429        // Run until the actor-spawn endpoint stops on its own, or the spawner
430        // asks us to drain and stop it. A graceful stop drains the endpoint,
431        // tearing the proc down cleanly so the process exits with a normal
432        // status the spawner observes via `waitpid`.
433        let mut status = actor_spawner_handle.status();
434        let mut control_open = true;
435        let final_status = loop {
436            let current = status.borrow().clone();
437            if current.is_terminal() {
438                break current;
439            }
440            tokio::select! {
441                changed = status.changed() => {
442                    if changed.is_err() {
443                        break status.borrow().clone();
444                    }
445                }
446                stop = control_rx.recv(), if control_open => {
447                    match stop {
448                        Ok(StopProc { mode, reason }) => {
449                            let _ = match mode {
450                                StopMode::Stop => actor_spawner_handle.stop(&reason),
451                                StopMode::DrainAndStop => actor_spawner_handle.drain_and_stop(&reason),
452                            };
453                        }
454                        Err(_) => control_open = false,
455                    }
456                }
457            }
458        };
459
460        let mut proc = proc;
461        let _ = proc
462            .destroy_and_wait(Duration::from_secs(5), "actor-spawn endpoint exited")
463            .await;
464        serve.stop("actor-spawn endpoint exited");
465        let _ = serve.join().await;
466        Ok(final_status)
467    }
468
469    /// Serve `proc`'s gateway on a fresh Unix socket of its own, returning
470    /// the serve handle and the bound location to advertise back to the
471    /// spawner. The child serves on its own socket — not the
472    /// spawner-derived proc address — so the spawner can dial it and
473    /// install a via-peer route, mirroring how `Host` dials a spawned
474    /// proc's own socket. Serving sets the gateway's default location to
475    /// the bound socket; the caller restores the advertised location.
476    fn serve_own_socket(proc: &Proc) -> anyhow::Result<(GatewayServeHandle, Location)> {
477        let serve = proc
478            .gateway()
479            .serve(ChannelAddr::any(ChannelTransport::Unix))?;
480        // `serve` sets the gateway's default location to the freshly bound
481        // socket; read it back rather than threading the address out of serve.
482        let gateway_addr = proc.gateway().default_location();
483        Ok((serve, gateway_addr))
484    }
485}
486
487#[derive(Debug)]
488struct UnixProcSession {
489    session_id: Uid,
490    supervisor: PortRef<SupervisorEvent>,
491    options: SupervisionOptions,
492    liveness_handle: AnyActorHandle,
493}
494
495/// Outcome of reaping the child OS process. A process either exits with a
496/// status code or is terminated by a signal, never both, so these are
497/// distinct variants rather than a pair of `Option`s.
498#[derive(Clone, Debug, Serialize, Deserialize, Named)]
499enum UnixProcExited {
500    Exited { code: i32 },
501    Signalled { signal: i32 },
502    WaitFailed { error: String },
503}
504wirevalue::register_type!(UnixProcExited);
505
506impl UnixProcExited {
507    fn from_wait_result(result: std::io::Result<ExitStatus>) -> Self {
508        let status = match result {
509            Ok(status) => status,
510            Err(error) => {
511                return Self::WaitFailed {
512                    error: error.to_string(),
513                };
514            }
515        };
516        match (status.code(), exit_signal(&status)) {
517            (Some(code), None) => Self::Exited { code },
518            (None, Some(signal)) => Self::Signalled { signal },
519            (code, signal) => panic!(
520                "unix proc wait returned an impossible status (code={code:?}, signal={signal:?})"
521            ),
522        }
523    }
524
525    fn actor_status(&self, stop_reason: Option<&str>) -> ActorStatus {
526        match (self, stop_reason) {
527            (Self::WaitFailed { error }, _) => {
528                ActorStatus::generic_failure(format!("unix proc wait failed: {error}"))
529            }
530            // A requested stop makes any process exit a clean actor stop.
531            (Self::Exited { .. } | Self::Signalled { .. }, Some(reason)) => {
532                ActorStatus::Stopped(reason.to_string())
533            }
534            (Self::Exited { code: 0 }, None) => {
535                ActorStatus::Stopped("unix proc exited".to_string())
536            }
537            (Self::Exited { .. } | Self::Signalled { .. }, None) => {
538                ActorStatus::generic_failure(self.describe())
539            }
540        }
541    }
542
543    fn describe(&self) -> String {
544        match self {
545            Self::WaitFailed { error } => format!("wait failed: {error}"),
546            Self::Exited { code } => format!("exited with code {code}"),
547            Self::Signalled { signal } => match Signal::try_from(*signal) {
548                Ok(sig) => format!("terminated by signal {signal} ({})", sig.as_str()),
549                Err(_) => format!("terminated by signal {signal}"),
550            },
551        }
552    }
553}
554
555#[derive(Debug)]
556#[hyperactor::export(WorkerCommand, UnixProcExited, ActorSupervisionEvent)]
557struct UnixProcWorker {
558    uid: Uid,
559    proc_addr: ProcAddr,
560    actor_spawner: ActorRef<SpawnActor>,
561    command: UnixProcCommand,
562    supervise: Option<Supervise>,
563    session: Option<UnixProcSession>,
564    peer_guard: Option<PeerAttachGuard>,
565    control: Option<PortRef<StopProc>>,
566    pid: Option<u32>,
567    stop_reason: Option<String>,
568    /// Resolves when the OS-process reaper observes the child exit. Lets
569    /// `handle_stop` wait for a graceful drain before force-signaling.
570    child_exit: Option<oneshot::Receiver<()>>,
571}
572
573impl UnixProcWorker {
574    fn new(
575        uid: Uid,
576        proc_addr: ProcAddr,
577        actor_spawner: ActorRef<SpawnActor>,
578        command: UnixProcCommand,
579        supervise: Supervise,
580    ) -> Self {
581        Self {
582            uid,
583            proc_addr,
584            actor_spawner,
585            command,
586            supervise: Some(supervise),
587            session: None,
588            peer_guard: None,
589            control: None,
590            pid: None,
591            stop_reason: None,
592            child_exit: None,
593        }
594    }
595
596    fn launch(&mut self, this: &Instance<Self>) -> anyhow::Result<()> {
597        let rendezvous = token::create(
598            this,
599            (),
600            this.port::<token::Joined<UnixProcJoin>>().bind(),
601            TokenOptions {
602                policy: TokenPolicy::Once,
603            },
604        )?;
605        let token = UnixProcToken::new(
606            self.proc_addr.clone(),
607            this.port::<ActorSupervisionEvent>().bind(),
608            rendezvous,
609        );
610
611        let mut child = self.command.spawn(&token)?;
612        self.pid = child.id();
613        let notify = this.port::<UnixProcExited>();
614        let client = Instance::<()>::self_client();
615        let (exit_tx, exit_rx) = oneshot::channel();
616        self.child_exit = Some(exit_rx);
617        tokio::spawn(async move {
618            let exited = UnixProcExited::from_wait_result(child.wait().await);
619            // Unblock a graceful `handle_stop` waiting on the exit, then report
620            // the terminal status to the worker's own port.
621            let _ = exit_tx.send(());
622            let _ = notify.post(client, exited);
623        });
624        Ok(())
625    }
626
627    /// Wait up to `grace` for the OS-process reaper to observe the child exit.
628    /// Returns `true` if the child exited in time (a clean drain); `false` if
629    /// the wait timed out or the reaper vanished without reporting, in which
630    /// case the caller should force the process down.
631    async fn await_child_exit(&mut self, grace: Duration) -> bool {
632        let Some(exit) = self.child_exit.take() else {
633            return true;
634        };
635        matches!(tokio::time::timeout(grace, exit).await, Ok(Ok(())))
636    }
637
638    fn reject_and_stop(
639        &mut self,
640        cx: &Context<Self>,
641        supervise: &Supervise,
642        reason: impl Into<String>,
643    ) {
644        reject_supervise(cx, supervise, reason);
645        self.signal_child(Signal::SIGKILL, "rejected proc supervision");
646    }
647
648    fn signal_child(&mut self, sig: Signal, reason: &str) {
649        let Some(pid) = self.pid else {
650            return;
651        };
652        if let Err(error) = signal_proc_group(pid, sig) {
653            let _ = (error, reason);
654        }
655    }
656
657    fn ensure_session(&self, session_id: Uid) -> anyhow::Result<Uid> {
658        let Some(session) = &self.session else {
659            anyhow::bail!("unix proc worker is not supervised");
660        };
661        if session.session_id != session_id {
662            anyhow::bail!("remote supervision session id mismatch");
663        }
664        Ok(session_id)
665    }
666
667    async fn link_proc(
668        &mut self,
669        cx: &Context<'_, Self>,
670        joined: UnixProcJoin,
671    ) -> anyhow::Result<()> {
672        let Some(supervise) = self.supervise.take() else {
673            self.signal_child(Signal::SIGKILL, "duplicate unix proc join");
674            return Ok(());
675        };
676        if joined.actor_spawner != self.actor_spawner {
677            self.reject_and_stop(
678                cx,
679                &supervise,
680                format!(
681                    "proc joined with unexpected actor-spawn endpoint {}; expected {}",
682                    joined.actor_spawner.actor_addr(),
683                    self.actor_spawner.actor_addr()
684                ),
685            );
686            return Ok(());
687        }
688
689        let gateway_addr = joined.gateway_addr.addr().clone();
690        let child_sender = match MailboxClient::dial(gateway_addr.clone()) {
691            Ok(sender) => sender,
692            Err(error) => {
693                self.reject_and_stop(
694                    cx,
695                    &supervise,
696                    format!("failed to dial spawned proc at {}: {}", gateway_addr, error),
697                );
698                return Ok(());
699            }
700        };
701        let peer_guard = match cx
702            .instance()
703            .proc()
704            .gateway()
705            .attach_peer(self.uid.clone(), child_sender.into_boxed())
706        {
707            Ok(guard) => guard,
708            Err(error) => {
709                self.reject_and_stop(
710                    cx,
711                    &supervise,
712                    format!("failed to attach spawned proc route: {error}"),
713                );
714                return Ok(());
715            }
716        };
717
718        let liveness_handle = match supervise.liveness.clone().spawn_worker(cx).await {
719            Ok(liveness_handle) => liveness_handle,
720            Err(error) => {
721                self.reject_and_stop(
722                    cx,
723                    &supervise,
724                    format!("failed to spawn proc liveness actor: {error}"),
725                );
726                return Ok(());
727            }
728        };
729        let supervisor = supervise.supervisor.clone();
730        self.peer_guard = Some(peer_guard);
731        self.control = Some(joined.control);
732        self.session = Some(UnixProcSession {
733            session_id: supervise.session_id.clone(),
734            supervisor: supervise.supervisor,
735            options: supervise.options,
736            liveness_handle,
737        });
738        supervisor.post(
739            cx,
740            SupervisorEvent::Linked {
741                session_id: supervise.session_id,
742                worker: cx.port::<WorkerCommand>().bind(),
743                child: self.actor_spawner.actor_addr().clone(),
744                display_name: Some(self.uid.to_string()),
745            },
746        );
747        Ok(())
748    }
749
750    fn handle_liveness_event(
751        &mut self,
752        cx: &impl context::Actor,
753        event: &ActorSupervisionEvent,
754    ) -> anyhow::Result<bool> {
755        let Some(session) = &self.session else {
756            return Ok(true);
757        };
758        if session.liveness_handle.actor_id() != &event.actor_id {
759            return Ok(!event.is_error());
760        }
761
762        let session = self
763            .session
764            .take()
765            .expect("liveness event must have an active session");
766        let _ = session
767            .liveness_handle
768            .stop("proc supervision liveness failed");
769        session.supervisor.post(
770            cx,
771            SupervisorEvent::SupervisionEvent {
772                session_id: session.session_id.clone(),
773                event: ActorSupervisionEvent::new(
774                    self.actor_spawner.actor_addr().clone(),
775                    Some(self.uid.to_string()),
776                    ActorStatus::generic_failure("proc supervision liveness failed"),
777                    None,
778                ),
779                disposition: RemoteActorDisposition::Unreachable,
780            },
781        );
782        if session.options.orphan_policy == OrphanPolicy::Stop {
783            self.signal_child(Signal::SIGTERM, "proc supervision liveness failed");
784        }
785        Ok(true)
786    }
787
788    fn unlink_session(&mut self, reason: &str) -> Option<UnixProcSession> {
789        let session = self.session.take()?;
790        let _ = session.liveness_handle.stop(reason);
791        Some(session)
792    }
793
794    /// Begin stopping the child proc. When linked, posts a graceful
795    /// [`StopProc`] to the child's control endpoint and schedules a hard
796    /// kill after the configured grace period ([`crate::config::STOP_GRACE_PERIOD`])
797    /// in case the drain stalls; otherwise signals the OS process directly. Does
798    /// not exit the worker — it exits once the process is reaped (see
799    /// [`Handler<UnixProcExited>`]).
800    fn request_child_stop(&mut self, cx: &Context<Self>, mode: StopMode, reason: &str) {
801        self.stop_reason = Some(reason.to_string());
802        let Some(control) = &self.control else {
803            self.signal_child(Signal::SIGTERM, reason);
804            return;
805        };
806        control.post(
807            cx,
808            StopProc {
809                mode,
810                reason: reason.to_string(),
811            },
812        );
813        // After the grace period, hard-kill the proc with a `KillProc`
814        // self-message — a no-op if it already drained and exited, and
815        // discarded outright if this worker has stopped by then.
816        cx.post_after(
817            cx,
818            KillProc {
819                reason: reason.to_string(),
820            },
821            hyperactor_config::global::get(crate::config::STOP_GRACE_PERIOD),
822        );
823    }
824}
825
826/// Self-message: hard-kill the child OS process. The worker posts this to
827/// itself when a graceful stop misses the grace period
828/// ([`crate::config::STOP_GRACE_PERIOD`]).
829#[derive(Debug)]
830struct KillProc {
831    reason: String,
832}
833
834#[async_trait]
835impl Actor for UnixProcWorker {
836    async fn init(&mut self, this: &Instance<Self>) -> anyhow::Result<()> {
837        if let Err(error) = self.launch(this)
838            && let Some(supervise) = self.supervise.take()
839        {
840            reject_supervise(
841                this,
842                &supervise,
843                format!("failed to launch unix proc: {error}"),
844            );
845            this.exit("failed to launch unix proc")?;
846        }
847        Ok(())
848    }
849
850    async fn handle_supervision_event(
851        &mut self,
852        this: &Instance<Self>,
853        event: &ActorSupervisionEvent,
854    ) -> anyhow::Result<bool> {
855        self.handle_liveness_event(this, event)
856    }
857
858    async fn handle_stop(
859        &mut self,
860        this: &Instance<Self>,
861        mode: StopMode,
862        reason: &str,
863    ) -> anyhow::Result<()> {
864        self.stop_reason = Some(reason.to_string());
865        let _ = self.unlink_session(reason);
866        // Ask the proc to drain gracefully over the control channel and wait for
867        // the OS process to exit on its own; signal it down only if the drain
868        // overruns the grace period. Unlike `request_child_stop`, this path must
869        // exit the worker itself, so it waits inline rather than escalating
870        // through a `KillProc` self-message.
871        if let Some(control) = self.control.take() {
872            control.post(
873                this,
874                StopProc {
875                    mode,
876                    reason: reason.to_string(),
877                },
878            );
879            let grace = hyperactor_config::global::get(crate::config::STOP_GRACE_PERIOD);
880            if !self.await_child_exit(grace).await {
881                self.signal_child(Signal::SIGTERM, reason);
882            }
883        } else {
884            // Not linked, so there is no control channel to drain over; signal
885            // the OS process directly.
886            self.signal_child(Signal::SIGTERM, reason);
887        }
888        this.close();
889        match mode {
890            StopMode::Stop => this.exit(reason)?,
891            StopMode::DrainAndStop => this.exit_after_drain(reason)?,
892        }
893        Ok(())
894    }
895}
896
897#[async_trait]
898impl Handler<token::Joined<UnixProcJoin>> for UnixProcWorker {
899    async fn handle(
900        &mut self,
901        cx: &Context<Self>,
902        message: token::Joined<UnixProcJoin>,
903    ) -> anyhow::Result<()> {
904        self.link_proc(cx, message.peer).await
905    }
906}
907
908#[async_trait]
909impl Handler<WorkerCommand> for UnixProcWorker {
910    async fn handle(&mut self, cx: &Context<Self>, message: WorkerCommand) -> anyhow::Result<()> {
911        match message {
912            WorkerCommand::Stop {
913                session_id,
914                mode,
915                reason,
916            } => {
917                self.ensure_session(session_id)?;
918                // Drain the proc gracefully over the actor channel, hard-
919                // killing the OS process only if the drain misses the grace
920                // period. The worker exits once the process is reaped (see
921                // `Handler<UnixProcExited>`).
922                self.request_child_stop(cx, mode, &reason);
923            }
924            WorkerCommand::Unlink { session_id, reason } => {
925                self.ensure_session(session_id.clone())?;
926                if let Some(session) = self.unlink_session(&reason) {
927                    if session.options.orphan_policy == OrphanPolicy::Stop {
928                        self.stop_reason = Some(reason.clone());
929                        self.signal_child(Signal::SIGTERM, &reason);
930                    }
931                    let _ = session
932                        .supervisor
933                        .post(cx, SupervisorEvent::Unlinked { session_id, reason });
934                }
935            }
936        }
937        Ok(())
938    }
939}
940
941#[async_trait]
942impl Handler<UnixProcExited> for UnixProcWorker {
943    async fn handle(&mut self, cx: &Context<Self>, message: UnixProcExited) -> anyhow::Result<()> {
944        self.pid = None;
945        self.peer_guard.take();
946        self.control = None;
947        let actor_status = message.actor_status(self.stop_reason.as_deref());
948        if let Some(session) = self.unlink_session("unix proc exited") {
949            session.supervisor.post(
950                cx,
951                SupervisorEvent::SupervisionEvent {
952                    session_id: session.session_id,
953                    event: ActorSupervisionEvent::new(
954                        self.actor_spawner.actor_addr().clone(),
955                        Some(self.uid.to_string()),
956                        actor_status,
957                        None,
958                    ),
959                    disposition: RemoteActorDisposition::Terminal,
960                },
961            );
962        } else if let Some(supervise) = self.supervise.take() {
963            reject_supervise(
964                cx,
965                &supervise,
966                format!("unix proc exited before joining: {}", message.describe()),
967            );
968        }
969        cx.exit("unix proc exited")?;
970        Ok(())
971    }
972}
973
974#[async_trait]
975impl Handler<ActorSupervisionEvent> for UnixProcWorker {
976    async fn handle(
977        &mut self,
978        cx: &Context<Self>,
979        event: ActorSupervisionEvent,
980    ) -> anyhow::Result<()> {
981        // A top-level supervision event forwarded by the child proc's
982        // coordinator. Clean lifecycle events need no action — the proc-exit
983        // path reports a clean stop. A failure is surfaced to the caller at
984        // once, with full detail, then the proc is drained and stopped.
985        if !event.is_error() {
986            return Ok(());
987        }
988        if let Some(session) = self.unlink_session("child proc reported a failure") {
989            session.supervisor.post(
990                cx,
991                SupervisorEvent::SupervisionEvent {
992                    session_id: session.session_id,
993                    event,
994                    disposition: RemoteActorDisposition::Terminal,
995                },
996            );
997        }
998        self.request_child_stop(cx, StopMode::DrainAndStop, "child proc reported a failure");
999        Ok(())
1000    }
1001}
1002
1003#[async_trait]
1004impl Handler<KillProc> for UnixProcWorker {
1005    async fn handle(&mut self, _cx: &Context<Self>, message: KillProc) -> anyhow::Result<()> {
1006        // Hard kill: a graceful drain missed the grace period. A no-op if the
1007        // process already exited.
1008        self.signal_child(Signal::SIGKILL, &message.reason);
1009        Ok(())
1010    }
1011}
1012
1013fn reject_supervise(cx: &impl context::Actor, supervise: &Supervise, reason: impl Into<String>) {
1014    let _ = supervise.supervisor.post(
1015        cx,
1016        SupervisorEvent::SuperviseRejected {
1017            session_id: supervise.session_id.clone(),
1018            reason: reason.into(),
1019        },
1020    );
1021}
1022
1023fn parse_env<T>(key: &str) -> anyhow::Result<T>
1024where
1025    T: std::str::FromStr,
1026    T::Err: Into<anyhow::Error>,
1027{
1028    std::env::var(key)?
1029        .parse()
1030        .map_err(|error: T::Err| error.into())
1031}
1032
1033/// Send `sig` to the child's process group, mirroring how the host bootstrap
1034/// launcher signals procs. The child is its own group leader (see `launch`), so
1035/// the negative pid targets the whole proc tree rather than just the leader. A
1036/// vanished group (`ESRCH`) is treated as success, since the goal — that group
1037/// being gone — is already met.
1038fn signal_proc_group(pid: u32, sig: Signal) -> nix::Result<()> {
1039    let pgid = Pid::from_raw(-(pid as i32));
1040    match signal::kill(pgid, sig) {
1041        Ok(()) | Err(Errno::ESRCH) => Ok(()),
1042        Err(err) => Err(err),
1043    }
1044}
1045
1046/// Ask the kernel to SIGKILL this process if its parent (the spawner) dies,
1047/// mirroring the host bootstrap safety net. A normal stop arrives over the
1048/// control channel; this only guards against the spawner vanishing without one.
1049/// Best-effort, but if the parent already died before `prctl` ran no signal
1050/// will arrive, so the reparent is detected and this process exits at once.
1051fn install_pdeathsig_kill() -> std::io::Result<()> {
1052    #[cfg(target_os = "linux")]
1053    {
1054        // SAFETY: `getppid` takes no arguments and only reads the parent pid.
1055        let ppid_before = unsafe { libc::getppid() };
1056        // SAFETY: `prctl(PR_SET_PDEATHSIG, ...)` reads only scalar arguments.
1057        let rc = unsafe { libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL as libc::c_int) };
1058        if rc != 0 {
1059            return Err(std::io::Error::last_os_error());
1060        }
1061        // SAFETY: as above; re-read the parent pid to detect a reparent.
1062        if unsafe { libc::getppid() } != ppid_before {
1063            std::process::exit(0);
1064        }
1065    }
1066    Ok(())
1067}
1068
1069fn exit_signal(status: &ExitStatus) -> Option<i32> {
1070    #[cfg(unix)]
1071    {
1072        use std::os::unix::process::ExitStatusExt as _;
1073        status.signal()
1074    }
1075    #[cfg(not(unix))]
1076    {
1077        let _ = status;
1078        None
1079    }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use hyperactor::Label;
1085    use hyperactor::ProcId;
1086
1087    use super::*;
1088
1089    #[tokio::test]
1090    async fn serves_on_its_own_socket_not_the_spawner_address() {
1091        // A child proc address derived from a spawner: it is advertised
1092        // through the spawner's terminal address, which the child must not
1093        // serve on.
1094        let spawner = ProcAddr::new(
1095            ProcId::new(Uid::instance(Label::strip("spawner")), None),
1096            Location::from(ChannelAddr::any(ChannelTransport::Unix)),
1097        );
1098        let proc_addr =
1099            spawned_proc_addr_for_spawner_addr(&spawner, &Uid::instance(Label::strip("child")));
1100
1101        let proc = Proc::isolated();
1102        let (mut serve, gateway_addr) = UnixProc::serve_own_socket(&proc).unwrap();
1103
1104        let served = gateway_addr
1105            .as_addr()
1106            .expect("child reports a terminal address, not a via route");
1107        assert!(
1108            matches!(served, ChannelAddr::Unix(_)),
1109            "child serves on its own unix socket, got {served}"
1110        );
1111        assert_ne!(
1112            served,
1113            proc_addr.addr(),
1114            "child must serve on its own socket, not the spawner-derived address"
1115        );
1116
1117        serve.stop("test complete");
1118        let _ = serve.join().await;
1119    }
1120
1121    #[test]
1122    fn requested_stop_reports_stopped_even_when_process_exits_by_signal() {
1123        let status = UnixProcExited::Signalled {
1124            signal: libc::SIGTERM,
1125        }
1126        .actor_status(Some("caller stopped proc"));
1127
1128        assert_eq!(
1129            status,
1130            ActorStatus::Stopped("caller stopped proc".to_string())
1131        );
1132    }
1133
1134    #[test]
1135    fn unexpected_signal_exit_reports_failure() {
1136        let status = UnixProcExited::Signalled {
1137            signal: libc::SIGTERM,
1138        }
1139        .actor_status(None);
1140
1141        assert!(status.is_failed());
1142    }
1143
1144    #[test]
1145    fn describe_names_the_terminating_signal() {
1146        let exited = UnixProcExited::Signalled {
1147            signal: libc::SIGTERM,
1148        };
1149        // SIGTERM is 15; `describe` looks the name up from the number.
1150        assert_eq!(
1151            exited.describe(),
1152            format!("terminated by signal {} (SIGTERM)", libc::SIGTERM)
1153        );
1154    }
1155}