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(¶ms, 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}