Skip to main content

actr_framework/guest/
dynclib_abi.rs

1//! DynClib-only C ABI for actr workloads.
2//!
3//! This module is the handwritten C ABI used by the DynClib workload variant
4//! (loaded via dlopen). The WASM variant does NOT consume these types — it
5//! goes through wit-bindgen-generated code against `core/framework/wit/actr-workload.wit`.
6//!
7//! Do NOT add wasm-path code paths here. Do NOT reference this module from
8//! the wasm guest adapter. This module is kept in sync with the WIT contract
9//! by `tools/wit-lint`.
10
11use crate::Dest;
12use actr_protocol::prost::Message as ProstMessage;
13use actr_protocol::{ActrError, ActrId, ActrType, DataChunk, PayloadType};
14
15/// ABI error codes.
16pub mod code {
17    /// Operation succeeded.
18    pub const SUCCESS: i32 = 0;
19    /// Generic unrecoverable error.
20    pub const GENERIC_ERROR: i32 = -1;
21    /// Initialization failed.
22    pub const INIT_FAILED: i32 = -2;
23    /// Message handling failed.
24    pub const HANDLE_FAILED: i32 = -3;
25    /// Memory allocation failed.
26    pub const ALLOC_FAILED: i32 = -4;
27    /// Protocol / codec error.
28    pub const PROTOCOL_ERROR: i32 = -5;
29    /// Guest-provided reply buffer is too small.
30    pub const BUFFER_TOO_SMALL: i32 = -6;
31    /// Unsupported runtime operation code.
32    pub const UNSUPPORTED_OP: i32 = -7;
33}
34
35/// Internal ABI version numbers.
36pub mod version {
37    /// ABI version 1.
38    pub const V1: u32 = 1;
39}
40
41/// Runtime operation codes carried inside [`AbiFrame`].
42pub mod op {
43    pub const HOST_CALL: u32 = 1;
44    pub const HOST_TELL: u32 = 2;
45    pub const HOST_CALL_RAW: u32 = 3;
46    pub const HOST_DISCOVER: u32 = 4;
47    pub const HOST_REGISTER_STREAM: u32 = 5;
48    pub const HOST_UNREGISTER_STREAM: u32 = 6;
49    pub const HOST_SEND_DATA_CHUNK: u32 = 7;
50    pub const GUEST_HANDLE: u32 = 101;
51    pub const GUEST_DATA_CHUNK: u32 = 102;
52    pub const GUEST_LIFECYCLE: u32 = 103;
53    pub const GUEST_HOOK: u32 = 104;
54}
55
56/// Lifecycle hook identifiers carried by [`GuestLifecycleV1`].
57pub mod lifecycle_hook {
58    pub const ON_START: u32 = 1;
59    pub const ON_READY: u32 = 2;
60    pub const ON_STOP: u32 = 3;
61}
62
63/// `WebRtcPeerStatus` discriminants carried by [`PeerEventV1::status`].
64/// One-to-one with `actr_framework::WebRtcPeerStatus`; 0-based to mirror
65/// the enum's declaration order. `optional` prost fields track presence, so
66/// `Some(IDLE)` is distinct from `None`.
67pub mod webrtc_peer_status {
68    pub const IDLE: u32 = 0;
69    pub const CONNECTING: u32 = 1;
70    pub const CONNECTED: u32 = 2;
71    pub const RECOVERING: u32 = 3;
72}
73
74/// Observation hook identifiers carried by [`GuestHookV1`].
75pub mod runtime_hook {
76    pub const ON_SIGNALING_CONNECTING: u32 = 1;
77    pub const ON_SIGNALING_CONNECTED: u32 = 2;
78    pub const ON_SIGNALING_DISCONNECTED: u32 = 3;
79    pub const ON_WEBSOCKET_CONNECTING: u32 = 4;
80    pub const ON_WEBSOCKET_CONNECTED: u32 = 5;
81    pub const ON_WEBSOCKET_DISCONNECTED: u32 = 6;
82    pub const ON_WEBRTC_CONNECTING: u32 = 7;
83    pub const ON_WEBRTC_CONNECTED: u32 = 8;
84    pub const ON_WEBRTC_DISCONNECTED: u32 = 9;
85    pub const ON_CREDENTIAL_RENEWED: u32 = 10;
86    pub const ON_CREDENTIAL_EXPIRING: u32 = 11;
87    pub const ON_MAILBOX_BACKPRESSURE: u32 = 12;
88}
89
90/// Dedicated payload used by `actr_init`.
91#[derive(Clone, PartialEq, prost::Message)]
92pub struct InitPayloadV1 {
93    #[prost(uint32, tag = "1")]
94    pub version: u32,
95    #[prost(string, tag = "2")]
96    pub actr_type: String,
97    #[prost(bytes = "vec", tag = "3")]
98    pub credential: Vec<u8>,
99    #[prost(bytes = "vec", tag = "4")]
100    pub actor_id: Vec<u8>,
101    #[prost(uint32, tag = "5")]
102    pub realm_id: u32,
103}
104
105/// Runtime frame used by both host->guest and guest->host invocation.
106///
107/// TODO: This type is temporarily `pub` because `actr_hyper` still performs
108/// cross-crate host-side ABI encoding and decoding through
109/// `actr_framework::guest::dynclib_abi`. Once the shared runtime ABI types
110/// are moved to a better ownership boundary, narrow this visibility to the
111/// intended internal-only surface.
112#[derive(Clone, PartialEq, prost::Message)]
113pub struct AbiFrame {
114    #[prost(uint32, tag = "1")]
115    pub abi_version: u32,
116    #[prost(uint32, tag = "2")]
117    pub op: u32,
118    #[prost(bytes = "vec", tag = "3")]
119    pub payload: Vec<u8>,
120}
121
122/// Runtime reply frame.
123///
124/// TODO: Keep visibility aligned with [`AbiFrame`]. This is public only as a
125/// temporary crate-topology workaround while host-side runtime code lives in
126/// `actr_hyper`.
127#[derive(Clone, PartialEq, prost::Message)]
128pub struct AbiReply {
129    #[prost(uint32, tag = "1")]
130    pub abi_version: u32,
131    #[prost(int32, tag = "2")]
132    pub status: i32,
133    #[prost(bytes = "vec", tag = "3")]
134    pub payload: Vec<u8>,
135}
136
137/// Invocation context injected by Hyper before entering guest handle logic.
138#[derive(Clone, PartialEq, prost::Message)]
139pub struct InvocationContextV1 {
140    #[prost(message, required, tag = "1")]
141    pub self_id: ActrId,
142    #[prost(message, optional, tag = "2")]
143    pub caller_id: Option<ActrId>,
144    #[prost(string, tag = "3")]
145    pub request_id: String,
146}
147
148/// Runtime host->guest handle payload.
149#[derive(Clone, PartialEq, prost::Message)]
150pub struct GuestHandleV1 {
151    #[prost(message, required, tag = "1")]
152    pub ctx: InvocationContextV1,
153    #[prost(bytes = "vec", tag = "2")]
154    pub rpc_envelope: Vec<u8>,
155    #[prost(uint64, tag = "3")]
156    pub bridge_token: u64,
157}
158
159/// Runtime host->guest DataChunk payload.
160#[derive(Clone, PartialEq, prost::Message)]
161pub struct GuestDataChunkV1 {
162    #[prost(message, required, tag = "1")]
163    pub chunk: DataChunk,
164    #[prost(message, required, tag = "2")]
165    pub sender: ActrId,
166    #[prost(uint64, tag = "3")]
167    pub bridge_token: u64,
168}
169
170/// Runtime host->guest lifecycle hook payload.
171#[derive(Clone, PartialEq, prost::Message)]
172pub struct GuestLifecycleV1 {
173    #[prost(message, required, tag = "1")]
174    pub ctx: InvocationContextV1,
175    #[prost(uint32, tag = "2")]
176    pub hook: u32,
177    #[prost(uint64, tag = "3")]
178    pub bridge_token: u64,
179}
180
181/// Wall-clock timestamp represented as seconds + nanoseconds since Unix epoch.
182#[derive(Clone, PartialEq, prost::Message)]
183pub struct TimestampV1 {
184    #[prost(uint64, tag = "1")]
185    pub seconds: u64,
186    #[prost(uint32, tag = "2")]
187    pub nanoseconds: u32,
188}
189
190/// Peer-scoped event payload for WebSocket / WebRTC hooks.
191#[derive(Clone, PartialEq, prost::Message)]
192pub struct PeerEventV1 {
193    #[prost(message, required, tag = "1")]
194    pub peer: ActrId,
195    #[prost(bool, optional, tag = "2")]
196    pub relayed: Option<bool>,
197    /// `WebRtcPeerStatus` discriminant (see [`webrtc_peer_status`]).
198    /// `None` for WebSocket events.
199    #[prost(uint32, optional, tag = "3")]
200    pub status: Option<u32>,
201}
202
203/// Credential lifecycle event payload.
204#[derive(Clone, PartialEq, prost::Message)]
205pub struct CredentialEventV1 {
206    #[prost(message, required, tag = "1")]
207    pub new_expiry: TimestampV1,
208}
209
210/// Mailbox backpressure event payload.
211#[derive(Clone, PartialEq, prost::Message)]
212pub struct BackpressureEventV1 {
213    #[prost(uint64, tag = "1")]
214    pub queue_len: u64,
215    #[prost(uint64, tag = "2")]
216    pub threshold: u64,
217}
218
219/// Runtime host->guest observation hook payload.
220#[derive(Clone, PartialEq, prost::Message)]
221pub struct GuestHookV1 {
222    #[prost(message, required, tag = "1")]
223    pub ctx: InvocationContextV1,
224    #[prost(uint32, tag = "2")]
225    pub hook: u32,
226    #[prost(message, optional, tag = "3")]
227    pub peer: Option<PeerEventV1>,
228    #[prost(message, optional, tag = "4")]
229    pub credential: Option<CredentialEventV1>,
230    #[prost(message, optional, tag = "5")]
231    pub backpressure: Option<BackpressureEventV1>,
232    #[prost(uint64, tag = "6")]
233    pub bridge_token: u64,
234}
235
236/// ABI-level destination encoding (replaces hand-rolled 0x00/0x01/0x02 byte protocol).
237#[derive(Clone, PartialEq, prost::Message)]
238pub struct DestV1 {
239    #[prost(oneof = "DestKind", tags = "1, 2, 3")]
240    pub kind: Option<DestKind>,
241}
242
243/// Destination variants carried inside [`DestV1`].
244#[derive(Clone, PartialEq, prost::Oneof)]
245pub enum DestKind {
246    #[prost(bool, tag = "1")]
247    Host(bool),
248    #[prost(bool, tag = "2")]
249    Workload(bool),
250    #[prost(message, tag = "3")]
251    Peer(ActrId),
252}
253
254impl DestV1 {
255    /// Construct a host destination.
256    pub fn host() -> Self {
257        Self {
258            kind: Some(DestKind::Host(true)),
259        }
260    }
261
262    /// Construct a workload destination.
263    pub fn workload() -> Self {
264        Self {
265            kind: Some(DestKind::Workload(true)),
266        }
267    }
268
269    /// Construct a peer destination.
270    pub fn peer(id: ActrId) -> Self {
271        Self {
272            kind: Some(DestKind::Peer(id)),
273        }
274    }
275
276    /// Convert the ABI destination into the framework destination.
277    pub fn try_into_dest(self) -> Result<Dest, ActrError> {
278        match self.kind {
279            Some(DestKind::Host(_)) => Ok(Dest::Host),
280            Some(DestKind::Workload(_)) => Ok(Dest::Workload),
281            Some(DestKind::Peer(id)) => Ok(Dest::Peer(id)),
282            None => Err(ActrError::DecodeFailure(
283                "destination kind is missing".into(),
284            )),
285        }
286    }
287}
288
289/// Runtime guest->host call payload.
290#[derive(Clone, PartialEq, prost::Message)]
291pub struct HostCallV1 {
292    #[prost(string, tag = "1")]
293    pub route_key: String,
294    #[prost(message, required, tag = "2")]
295    pub dest: DestV1,
296    #[prost(bytes = "vec", tag = "3")]
297    pub payload: Vec<u8>,
298}
299
300/// Runtime guest->host tell payload.
301#[derive(Clone, PartialEq, prost::Message)]
302pub struct HostTellV1 {
303    #[prost(string, tag = "1")]
304    pub route_key: String,
305    #[prost(message, required, tag = "2")]
306    pub dest: DestV1,
307    #[prost(bytes = "vec", tag = "3")]
308    pub payload: Vec<u8>,
309}
310
311/// Runtime guest->host raw call payload.
312#[derive(Clone, PartialEq, prost::Message)]
313pub struct HostCallRawV1 {
314    #[prost(string, tag = "1")]
315    pub route_key: String,
316    #[prost(message, required, tag = "2")]
317    pub target: ActrId,
318    #[prost(bytes = "vec", tag = "3")]
319    pub payload: Vec<u8>,
320}
321
322/// Runtime guest->host discovery payload.
323#[derive(Clone, PartialEq, prost::Message)]
324pub struct HostDiscoverV1 {
325    #[prost(message, required, tag = "1")]
326    pub target_type: ActrType,
327}
328
329/// Runtime guest->host DataChunk registration payload.
330#[derive(Clone, PartialEq, prost::Message)]
331pub struct HostRegisterStreamV1 {
332    #[prost(string, tag = "1")]
333    pub stream_id: String,
334}
335
336/// Runtime guest->host DataChunk unregistration payload.
337#[derive(Clone, PartialEq, prost::Message)]
338pub struct HostUnregisterStreamV1 {
339    #[prost(string, tag = "1")]
340    pub stream_id: String,
341}
342
343/// Runtime guest->host DataChunk send payload.
344#[derive(Clone, PartialEq, prost::Message)]
345pub struct HostSendDataChunkV1 {
346    #[prost(message, required, tag = "1")]
347    pub dest: DestV1,
348    #[prost(message, required, tag = "2")]
349    pub chunk: DataChunk,
350    #[prost(enumeration = "PayloadType", tag = "3")]
351    pub payload_type: i32,
352}
353
354/// Payloads that can automatically construct runtime frames.
355pub trait AbiPayload: ProstMessage + Default + Sized {
356    const ABI_VERSION: u32;
357    const OP: u32;
358
359    fn to_frame(&self) -> Result<AbiFrame, i32> {
360        let mut payload = Vec::new();
361        self.encode(&mut payload)
362            .map_err(|_| code::PROTOCOL_ERROR)?;
363
364        Ok(AbiFrame {
365            abi_version: Self::ABI_VERSION,
366            op: Self::OP,
367            payload,
368        })
369    }
370
371    fn decode_payload(bytes: &[u8]) -> Result<Self, i32> {
372        Self::decode(bytes).map_err(|_| code::PROTOCOL_ERROR)
373    }
374}
375
376impl AbiPayload for HostCallV1 {
377    const ABI_VERSION: u32 = version::V1;
378    const OP: u32 = op::HOST_CALL;
379}
380
381impl AbiPayload for HostTellV1 {
382    const ABI_VERSION: u32 = version::V1;
383    const OP: u32 = op::HOST_TELL;
384}
385
386impl AbiPayload for HostCallRawV1 {
387    const ABI_VERSION: u32 = version::V1;
388    const OP: u32 = op::HOST_CALL_RAW;
389}
390
391impl AbiPayload for HostDiscoverV1 {
392    const ABI_VERSION: u32 = version::V1;
393    const OP: u32 = op::HOST_DISCOVER;
394}
395
396impl AbiPayload for HostRegisterStreamV1 {
397    const ABI_VERSION: u32 = version::V1;
398    const OP: u32 = op::HOST_REGISTER_STREAM;
399}
400
401impl AbiPayload for HostUnregisterStreamV1 {
402    const ABI_VERSION: u32 = version::V1;
403    const OP: u32 = op::HOST_UNREGISTER_STREAM;
404}
405
406impl AbiPayload for HostSendDataChunkV1 {
407    const ABI_VERSION: u32 = version::V1;
408    const OP: u32 = op::HOST_SEND_DATA_CHUNK;
409}
410
411impl AbiPayload for GuestHandleV1 {
412    const ABI_VERSION: u32 = version::V1;
413    const OP: u32 = op::GUEST_HANDLE;
414}
415
416impl AbiPayload for GuestDataChunkV1 {
417    const ABI_VERSION: u32 = version::V1;
418    const OP: u32 = op::GUEST_DATA_CHUNK;
419}
420
421impl AbiPayload for GuestLifecycleV1 {
422    const ABI_VERSION: u32 = version::V1;
423    const OP: u32 = op::GUEST_LIFECYCLE;
424}
425
426impl AbiPayload for GuestHookV1 {
427    const ABI_VERSION: u32 = version::V1;
428    const OP: u32 = op::GUEST_HOOK;
429}
430
431/// Encode a protobuf message into bytes.
432pub fn encode_message<M: ProstMessage>(message: &M) -> Result<Vec<u8>, i32> {
433    let mut out = Vec::new();
434    message.encode(&mut out).map_err(|_| code::PROTOCOL_ERROR)?;
435    Ok(out)
436}
437
438/// Decode a protobuf message from bytes.
439pub fn decode_message<M: ProstMessage + Default>(bytes: &[u8]) -> Result<M, i32> {
440    M::decode(bytes).map_err(|_| code::PROTOCOL_ERROR)
441}
442
443/// Encode a successful runtime reply.
444pub fn success_reply(payload: Vec<u8>) -> Result<Vec<u8>, i32> {
445    encode_message(&AbiReply {
446        abi_version: version::V1,
447        status: code::SUCCESS,
448        payload,
449    })
450}
451
452/// Encode a failed runtime reply.
453pub fn error_reply(status: i32, message: impl Into<Vec<u8>>) -> Result<Vec<u8>, i32> {
454    encode_message(&AbiReply {
455        abi_version: version::V1,
456        status,
457        payload: message.into(),
458    })
459}
460
461// ─────────────────────────────────────────────────────────────────────────────
462// Shared guest-side helpers (used by both WASM and DynClib contexts)
463// ─────────────────────────────────────────────────────────────────────────────
464
465/// Convert a [`crate::Dest`] to the ABI-level [`DestV1`].
466pub fn dest_to_v1(dest: &crate::Dest) -> DestV1 {
467    match dest {
468        crate::Dest::Host => DestV1::host(),
469        crate::Dest::Workload => DestV1::workload(),
470        crate::Dest::Peer(id) => DestV1::peer(id.clone()),
471    }
472}
473
474/// Convert an ABI-level [`DestV1`] back to [`crate::Dest`].
475///
476/// Returns `None` if the `kind` field is absent.
477pub fn dest_v1_to_dest(v1: &DestV1) -> Option<crate::Dest> {
478    v1.clone().try_into_dest().ok()
479}
480
481/// Convert an ABI error code to an [`actr_protocol::ActrError`].
482pub fn abi_error_to_actr(code_val: i32) -> actr_protocol::ActrError {
483    use actr_protocol::ActrError;
484    match code_val {
485        code::GENERIC_ERROR => ActrError::Internal("host returned generic ABI error".into()),
486        code::INIT_FAILED => ActrError::Internal("host initialization failed".into()),
487        code::HANDLE_FAILED => ActrError::Internal("guest handle failed".into()),
488        code::ALLOC_FAILED => ActrError::Internal("memory allocation failed".into()),
489        code::PROTOCOL_ERROR => ActrError::DecodeFailure("ABI payload decode failed".into()),
490        code::BUFFER_TOO_SMALL => {
491            ActrError::Internal("reply buffer too small for host invoke".into())
492        }
493        code::UNSUPPORTED_OP => ActrError::NotImplemented("unsupported ABI operation".into()),
494        other => ActrError::Internal(format!("unexpected ABI status code {other}")),
495    }
496}
497
498/// Convert an [`AbiReply`] with a non-success status to an [`actr_protocol::ActrError`].
499pub fn reply_to_actr_error(reply: AbiReply) -> actr_protocol::ActrError {
500    use actr_protocol::ActrError;
501    if reply.payload.is_empty() {
502        return abi_error_to_actr(reply.status);
503    }
504
505    let message = String::from_utf8(reply.payload)
506        .unwrap_or_else(|_| format!("guest returned status {}", reply.status));
507
508    match reply.status {
509        code::PROTOCOL_ERROR => ActrError::DecodeFailure(message),
510        code::UNSUPPORTED_OP => ActrError::NotImplemented(message),
511        _ => ActrError::Internal(message),
512    }
513}