Skip to main content

hyperactor_mesh/
client_root.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//! A native, language-independent client-root capability.
10//!
11//! A Hyperactor mesh program has exactly one root client. Rust and Python are
12//! alternative bootstrap implementations of that one root, not separate root
13//! namespaces; worker ProcAgents host actors but never become client roots.
14//!
15//! [`ClientRootRef`] is an opaque bearer capability to the root, carried in an
16//! actor's [`ActorEnvironment`](hyperactor::ActorEnvironment) and inherited by
17//! descendants. Its sole authority is to create or reuse a statically declared,
18//! root-owned service through the root ProcAgent's [`EnsureClientRootService`]
19//! handler.
20//!
21//! # CROOT invariant registry
22//!
23//! - **CROOT-1 (singleton binding):** the active client bootstrap binds
24//!   [`ClientRootApi`] on the program's one root ProcAgent and stores that exact
25//!   reference; worker ProcAgents never bind it.
26//! - **CROOT-2 (typed API attenuation):** [`ClientRootRef`] exposes only typed
27//!   ensure-service authority and no root administration.
28//! - **CROOT-3 (single identity):** exact name/type/parameter equality returns
29//!   one cached spawn result; a type or parameter-byte difference for the same
30//!   name conflicts. The service actor is created under a fresh random instance
31//!   id, which avoids the predictable collision a name-derived id would create;
32//!   the create path re-draws the id if it collides with one already present in
33//!   `actor_states`.
34//! - **CROOT-4 (worker rejection):** a worker ProcAgent has no bound client-root
35//!   handler, so a retyped worker reference reaches an unbound port and cannot
36//!   create a service.
37//! - **CROOT-5 (root ownership):** service lifetime belongs to the root
38//!   ProcAgent, never the requester; a reused reference to a service that has
39//!   reached a terminal state fails closed rather than returning a dead actor.
40//! - **CROOT-6 (validation):** a capability absent from the actor environment
41//!   fails closed — [`ClientRootRef::from_env`] returns an error rather than a
42//!   silent `None`, before any request is sent.
43//! - **CROOT-7 (continuity):** each created service receives the same
44//!   [`ClientRootRef`] in its native environment.
45//! - **CROOT-8 (runtime neutrality):** Rust and Python roots store the same
46//!   native capability under the same environment key.
47//! - **CROOT-9 (map layering):** a service's spawn result and lifecycle live
48//!   solely in the ProcAgent `actor_states` registry; the service-identity table
49//!   records only the (actor type, exact parameter bytes, instance id) a repeat
50//!   ensure must match. Only ensure writes that table, and only alongside the
51//!   `actor_states` entry, so the two never disagree.
52//! - **CROOT-10 (attestation boundary):** the only explicit attestation restores
53//!   `ActorRef<A>` immediately after the erased spawn reply whose requested and
54//!   recorded actor type was derived from that same `A`; every other reference is
55//!   bound or deserialized.
56//! - **CROOT-11 (handler resilience):** the `EnsureClientRootService` handler
57//!   disables undeliverable-return on its reply and always returns `Ok(())`,
58//!   reporting every expected failure through the typed reply; a malformed,
59//!   conflicting, or reply-undeliverable request never faults the root ProcAgent.
60//! - **CROOT-12 (crate layering):** every client-root type and policy stays in
61//!   `hyperactor_mesh` (or a higher composition crate); `hyperactor` remains a
62//!   generic attribute carrier with no static mesh dependency.
63
64use std::marker::PhantomData;
65
66use hyperactor::ActorAddr;
67use hyperactor::ActorEnvironment;
68use hyperactor::ActorHandle;
69use hyperactor::ActorRef;
70use hyperactor::Data;
71use hyperactor::Endpoint;
72use hyperactor::OncePortRef;
73use hyperactor::RemoteSpawn;
74use hyperactor::actor::remote::Remote;
75use hyperactor::context;
76use hyperactor::id::Label;
77use hyperactor_config::AttrValue;
78use hyperactor_config::declare_attrs;
79use serde::Deserialize;
80use serde::Serialize;
81use typeuri::Named;
82
83use crate::CodecError;
84use crate::Error;
85use crate::proc_agent::ProcAgent;
86
87/// A typed failure from a client-root ensure request.
88#[derive(Clone, Debug, Serialize, Deserialize, thiserror::Error)]
89pub enum ClientRootError {
90    /// The same service name was already created with a different actor type or
91    /// different serialized parameters.
92    #[error("conflicting client-root service specification for `{name}`")]
93    ConflictingSpec {
94        /// The service name that conflicted.
95        name: String,
96    },
97
98    /// The service actor failed to spawn (the failure is cached and not retried
99    /// implicitly).
100    #[error("client-root service `{name}` failed to spawn: {message}")]
101    Spawn {
102        /// The service name.
103        name: String,
104        /// The spawn failure, rendered as text (the underlying error is not
105        /// serializable).
106        message: String,
107    },
108
109    /// The service exists but is not usable: it has stopped, failed, or is
110    /// stopping after spawning, or the root is shutting down. The caller must not
111    /// treat it as a live actor.
112    #[error("client-root service `{name}` is not available: {reason}")]
113    Unavailable {
114        /// The service name.
115        name: String,
116        /// Why the service is unavailable.
117        reason: String,
118    },
119}
120
121/// Ensure a statically declared, root-owned service exists, returning its
122/// address. Posted to the root ProcAgent through [`ClientRootApi`].
123#[derive(Debug, Serialize, Deserialize, Named)]
124pub(crate) struct EnsureClientRootService {
125    /// The validated static service name.
126    pub(crate) service_name: Label,
127    /// The registered remote actor type name resolved from the descriptor.
128    pub(crate) actor_type: String,
129    /// The exact serialized `RemoteSpawn` parameters.
130    pub(crate) params: Data,
131    /// Typed reply channel.
132    pub(crate) reply: OncePortRef<EnsureClientRootServiceReply>,
133}
134
135/// The typed reply to [`EnsureClientRootService`]. A `Named` newtype so it can
136/// travel through a `OncePortRef`.
137#[derive(Debug, Serialize, Deserialize, Named)]
138pub(crate) struct EnsureClientRootServiceReply(pub(crate) Result<ActorAddr, ClientRootError>);
139
140// The narrow, restricted behavior a `ClientRootRef` can drive on the root
141// ProcAgent: exactly one message, no root administration. The behavior lives in
142// a private module because the macro emits a `pub` type; only a crate-visible
143// alias is re-exported, so the protocol never leaks past the crate.
144mod protocol {
145    use super::EnsureClientRootService;
146
147    hyperactor::behavior!(ClientRootApi, EnsureClientRootService);
148}
149pub(crate) use protocol::ClientRootApi;
150
151/// An opaque bearer capability to the program's one client root.
152///
153/// The inner behavior reference is never exposed. Possession of an inherited
154/// `ClientRootRef` is the authority under Hyperactor's trusted-actor model
155/// (CROOT-2); it is API attenuation, not actor authentication.
156#[derive(Clone, Serialize, Deserialize, Named, PartialEq, Eq, Hash)]
157pub struct ClientRootRef {
158    root: ActorRef<ClientRootApi>,
159}
160
161impl ClientRootRef {
162    /// Bind [`ClientRootApi`] on the program's root ProcAgent handle and wrap
163    /// the resulting reference (CROOT-1). Only the active root bootstrap calls
164    /// this; worker ProcAgents never bind the API.
165    pub fn bind(root: &ActorHandle<ProcAgent>) -> Self {
166        Self {
167            root: root.bind::<ClientRootApi>(),
168        }
169    }
170
171    /// Read the client-root capability from the supplied actor environment,
172    /// failing closed when it is absent (CROOT-6). Every consumer (starting
173    /// with RDMA) obtains the root here rather than re-deriving it, so the
174    /// missing-capability path is enforced in one place.
175    pub fn from_env(environment: &ActorEnvironment) -> crate::Result<ClientRootRef> {
176        environment.get(CLIENT_ROOT).ok_or(Error::MissingClientRoot)
177    }
178
179    /// Construct a reference from an already-bound behavior reference. Used by
180    /// the root ProcAgent to seed a created service's environment with the same
181    /// capability (CROOT-7).
182    pub(crate) fn from_ref(root: ActorRef<ClientRootApi>) -> Self {
183        Self { root }
184    }
185
186    /// The root behavior address, for typed-error attribution only.
187    pub(crate) fn addr(&self) -> &ActorAddr {
188        self.root.actor_addr()
189    }
190}
191
192// A bearer capability must not be copied out of config or diagnostics as a
193// reconstructible string. `AttrValue` imposes no display/parse round-trip law,
194// so display/`Debug` are redacted and textual parse always fails; serde still
195// carries the real bytes, which is how `ActorEnvironment` stores and retrieves
196// the value.
197impl std::fmt::Debug for ClientRootRef {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        f.write_str("ClientRootRef(<redacted>)")
200    }
201}
202
203impl AttrValue for ClientRootRef {
204    fn display(&self) -> String {
205        "<redacted>".to_string()
206    }
207
208    fn parse(_value: &str) -> Result<Self, anyhow::Error> {
209        anyhow::bail!("ClientRootRef is a bearer capability and cannot be parsed from text")
210    }
211}
212
213declare_attrs! {
214    /// The client-root capability carried in an actor's environment.
215    pub attr CLIENT_ROOT: ClientRootRef;
216}
217
218/// A statically declared, root-owned service: a fixed service name plus a
219/// registered actor type `A`. Callers supply `A::Params`; there is no
220/// caller-supplied runtime actor type or untyped address.
221pub struct ClientRootService<A: RemoteSpawn> {
222    name: &'static str,
223    _actor: PhantomData<fn() -> A>,
224}
225
226impl<A: RemoteSpawn> ClientRootService<A> {
227    /// Declare a statically named root-owned service.
228    ///
229    /// This constructs only the local typed descriptor. [`Self::ensure`] is
230    /// the operation that asks the root ProcAgent to create or reuse the actor.
231    pub const fn declare(name: &'static str) -> Self {
232        Self {
233            name,
234            _actor: PhantomData,
235        }
236    }
237
238    /// Ensure this service exists under `client_root`, returning a typed
239    /// reference to it. Concurrent identical ensures collapse on the serial root
240    /// ProcAgent; a different actor type or parameter bytes for the same name
241    /// returns [`ClientRootError::ConflictingSpec`].
242    pub async fn ensure(
243        &self,
244        cx: &impl context::Actor,
245        client_root: &ClientRootRef,
246        params: A::Params,
247    ) -> crate::Result<ActorRef<A>> {
248        let service_name = Label::new(self.name).map_err(|e| Error::Other(e.into()))?;
249        let actor_type = Remote::collect()
250            .name_of::<A>()
251            .ok_or_else(|| Error::ActorTypeNotRegistered(std::any::type_name::<A>().to_string()))?
252            .to_string();
253        let params = bincode::serde::encode_to_vec(&params, bincode::config::legacy())
254            .map_err(|e| Error::CodecError(CodecError::BincodeEncodeError(Box::new(e))))?;
255
256        let (reply_handle, reply_receiver) = cx
257            .mailbox()
258            .open_once_port::<EnsureClientRootServiceReply>();
259        client_root.root.post(
260            cx,
261            EnsureClientRootService {
262                service_name,
263                actor_type,
264                params,
265                reply: reply_handle.bind(),
266            },
267        );
268        // A failed reply delivery is a genuine call failure against the root; a
269        // typed `ClientRootError` carried in the reply surfaces transparently.
270        let EnsureClientRootServiceReply(result) = reply_receiver
271            .recv()
272            .await
273            .map_err(|e| Error::CallError(client_root.addr().clone(), e.into()))?;
274        let addr = result?;
275
276        // CROOT-10: `RemoteSpawn` erased `A` to `ActorAddr` after binding the
277        // actor. This request's registered actor type came from `A`, and the
278        // root ProcAgent returned only the matching service entry, so restore
279        // that discarded phantom type here. This is the module's sole `attest`.
280        Ok(ActorRef::<A>::attest(addr))
281    }
282}