Skip to content

Commit 9a99608

Browse files
committed
Change ma_send to require message type
1 parent 0564123 commit 9a99608

7 files changed

Lines changed: 163 additions & 13 deletions

File tree

src/entity.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -471,14 +471,21 @@ pub struct RuntimeManifest {
471471

472472
/// Outbound message queued by a plugin via the `ma_send` host function.
473473
///
474-
/// `to` — recipient DID (or DID-URL).
475-
/// `reply_to` — if set, marks this as a reply to the given message ID; the
476-
/// runtime will use `MESSAGE_TYPE_RPC_REPLY` for the wire message.
477-
/// `content` — raw payload bytes (e.g. CBOR-encoded RPC atom or JSON).
474+
/// `to` — recipient DID (or DID-URL).
475+
/// `content_type` — MIME type of the payload (e.g. `application/cbor`).
476+
/// `message_type` — envelope routing type (e.g. `application/x-ma-chat`). If
477+
/// `None` the runtime defaults to `MESSAGE_TYPE_RPC`. The
478+
/// protocol used for delivery is derived from this field; see
479+
/// `eventloop::protocol_for`.
480+
/// `reply_to` — if set, marks this as a reply; overrides `message_type` to
481+
/// `MESSAGE_TYPE_RPC_REPLY` and routes via `/ma/rpc/0.0.1`.
482+
/// `content` — raw payload bytes.
478483
#[derive(Debug, Clone, Serialize, Deserialize)]
479484
pub struct SendEnvelope {
480485
pub to: String,
481486
pub content_type: String,
487+
#[serde(default, skip_serializing_if = "Option::is_none")]
488+
pub message_type: Option<String>,
482489
#[serde(with = "serde_bytes")]
483490
pub content: Vec<u8>,
484491
pub reply_to: Option<String>,

src/eventloop.rs

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ use std::time::Duration;
1010
use anyhow::Result;
1111
use ma_core::config::Config;
1212
use ma_core::{
13-
Did, Inbox, IpfsGatewayResolver, MaEndpoint, Message, SigningKey, IPFS_PROTOCOL_ID,
14-
MESSAGE_TYPE_RPC, MESSAGE_TYPE_RPC_REPLY,
13+
Did, Inbox, IpfsGatewayResolver, MaEndpoint, Message, SigningKey, INBOX_PROTOCOL_ID,
14+
IPFS_PROTOCOL_ID, MESSAGE_TYPE_CRUD, MESSAGE_TYPE_CRUD_REPLY, MESSAGE_TYPE_IPFS_REQUEST,
15+
MESSAGE_TYPE_IPFS_STORE, MESSAGE_TYPE_RPC, MESSAGE_TYPE_RPC_REPLY,
1516
};
1617
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
1718
use tokio::sync::RwLock;
@@ -24,12 +25,27 @@ use crate::ipfs::IpfsServiceState;
2425
use crate::manifest::ManifestWriter;
2526
use crate::plugin::EntityRegistry;
2627
use crate::status::SharedStats;
27-
use crate::{bootstrap, crud, i18n, ipfs, rpc, status};
28+
use crate::{bootstrap, crud, i18n, inbox, ipfs, rpc, status};
29+
30+
/// Map a `message_type` string to the iroh delivery protocol.
31+
///
32+
/// Only RPC and its reply go to `/ma/rpc/0.0.1`; IPFS requests go to
33+
/// `/ma/ipfs/0.0.1`; CRUD goes to `/ma/crud/0.0.1`. Everything else
34+
/// (message, broadcast, chat, emote, unknown) falls back to `/ma/inbox/0.0.1`.
35+
fn protocol_for(msg_type: &str) -> &'static str {
36+
match msg_type {
37+
MESSAGE_TYPE_RPC | MESSAGE_TYPE_RPC_REPLY => rpc::RPC_PROTOCOL_ID,
38+
MESSAGE_TYPE_IPFS_REQUEST | MESSAGE_TYPE_IPFS_STORE => IPFS_PROTOCOL_ID,
39+
MESSAGE_TYPE_CRUD | MESSAGE_TYPE_CRUD_REPLY => crud::CRUD_PROTOCOL_ID,
40+
_ => INBOX_PROTOCOL_ID,
41+
}
42+
}
2843

2944
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
3045
pub async fn run(
3146
mut endpoint: Arc<dyn MaEndpoint>,
3247
rpc_messages: Inbox<Message>,
48+
inbox_messages: Inbox<Message>,
3349
mut crud_messages: Option<Inbox<Message>>,
3450
mut ipfs_state: Option<IpfsServiceState>,
3551
envelope_tx: UnboundedSender<(String, SendEnvelope)>,
@@ -202,12 +218,40 @@ pub async fn run(
202218
}
203219
}
204220

221+
// Drain /ma/inbox/0.0.1
222+
while let Some(mut message) = inbox_messages.pop(now) {
223+
debug!(
224+
from = %message.from,
225+
to = %message.to,
226+
message_type = %message.message_type,
227+
"{}", i18n::t("inbox-message-received")
228+
);
229+
let ctx = inbox::InboxHandlerCtx {
230+
our_did: Arc::from(our_did.as_str()),
231+
entity_registry: entity_registry.clone(),
232+
kubo_rpc_url: Arc::from(kubo_url.as_str()),
233+
};
234+
tokio::spawn(async move {
235+
if let Err(err) = tokio::time::timeout(
236+
Duration::from_secs(30),
237+
inbox::handle_inbox_message(&message, &ctx),
238+
)
239+
.await
240+
.unwrap_or_else(|_| Err(anyhow::anyhow!("inbox handler timed out")))
241+
{
242+
warn!(error = %err, from = %message.from, "inbox message rejected");
243+
}
244+
message.content.zeroize();
245+
message.signature.zeroize();
246+
});
247+
}
248+
205249
// Drain plugin outbox — envelopes sent fire-and-forget by ma_send/ma_reply.
206250
while let Ok((fragment, env)) = envelope_rx.try_recv() {
207251
let msg_type = if env.reply_to.is_some() {
208252
MESSAGE_TYPE_RPC_REPLY
209253
} else {
210-
MESSAGE_TYPE_RPC
254+
env.message_type.as_deref().unwrap_or(MESSAGE_TYPE_RPC)
211255
};
212256
let sender_did_url = format!("{our_did}#{fragment}");
213257
let recipient = match Did::try_from(env.to.as_str()) {
@@ -232,6 +276,7 @@ pub async fn run(
232276
}
233277
};
234278
msg.reply_to = env.reply_to;
279+
let protocol = protocol_for(msg_type);
235280
// Spawn each delivery independently so one unreachable peer
236281
// cannot block others. Cap the outbox-open at 5 seconds.
237282
let ep = Arc::clone(&endpoint);
@@ -240,7 +285,7 @@ pub async fn run(
240285
tokio::spawn(async move {
241286
match tokio::time::timeout(
242287
Duration::from_secs(5),
243-
ep.outbox(res.as_ref(), &base, rpc::RPC_PROTOCOL_ID),
288+
ep.outbox(res.as_ref(), &base, protocol),
244289
)
245290
.await
246291
{

src/inbox.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
//! Handler for the `/ma/inbox/0.0.1` service.
2+
//!
3+
//! Inbox messages are fire-and-forget: fragment-addressed messages are routed
4+
//! to the target entity's `handle_cast` (stateless) or `handle_call` (stateful,
5+
//! to allow state persistence), and the result is discarded. No reply is sent.
6+
//! Unfragmented (broadcast / runtime-level) messages are logged and dropped.
7+
8+
use std::sync::Arc;
9+
10+
use anyhow::Result;
11+
use tracing::{info, warn};
12+
13+
use crate::entity::{CastInput, LocalMessage, PluginKind, PluginMsg};
14+
use crate::plugin::EntityRegistry;
15+
16+
pub struct InboxHandlerCtx {
17+
pub our_did: Arc<str>,
18+
pub entity_registry: EntityRegistry,
19+
pub kubo_rpc_url: Arc<str>,
20+
}
21+
22+
pub async fn handle_inbox_message(message: &ma_core::Message, ctx: &InboxHandlerCtx) -> Result<()> {
23+
let fragment = extract_fragment(&message.to, &ctx.our_did);
24+
25+
let Some(fragment) = fragment else {
26+
info!(
27+
from = %message.from,
28+
to = %message.to,
29+
message_type = %message.message_type,
30+
"inbox: unfragmented message dropped"
31+
);
32+
return Ok(());
33+
};
34+
35+
let entity = ctx.entity_registry.read().await.get(fragment).cloned();
36+
37+
let Some(entity) = entity else {
38+
warn!(fragment = %fragment, "inbox: unknown entity fragment; dropped");
39+
return Ok(());
40+
};
41+
42+
info!(
43+
fragment = %fragment,
44+
from = %message.from,
45+
message_type = %message.message_type,
46+
"inbox: dispatching to entity"
47+
);
48+
49+
let local_msg = LocalMessage {
50+
id: message.id.clone(),
51+
from: message.from.clone(),
52+
to: message.to.clone(),
53+
created_at: message.created_at,
54+
expires: message.exp,
55+
reply_to: message.reply_to.clone(),
56+
content_type: message.content_type.clone(),
57+
content: message.content.clone(),
58+
};
59+
let cast_input = CastInput {
60+
msg: PluginMsg::from(&local_msg),
61+
};
62+
63+
let result = match entity.kind {
64+
PluginKind::Stateless => entity.handle_cast(&cast_input).await?,
65+
PluginKind::Stateful => entity.handle_call(&cast_input).await?,
66+
};
67+
68+
// Persist state if the entity called ma_set_state during this dispatch.
69+
if let Some(state_bytes) = result.pending_state {
70+
let kubo_url = Arc::clone(&ctx.kubo_rpc_url);
71+
let fragment_str = entity.fragment.clone();
72+
let entity_arc = Arc::clone(&entity);
73+
tokio::spawn(async move {
74+
match crate::kubo::dag_put(&kubo_url, &state_bytes).await {
75+
Ok(cid) => {
76+
entity_arc.mark_saved(state_bytes);
77+
info!(fragment = %fragment_str, cid = %cid, "inbox: entity state persisted");
78+
}
79+
Err(e) => {
80+
warn!(fragment = %fragment_str, error = %e, "inbox: failed to persist entity state");
81+
}
82+
}
83+
});
84+
}
85+
86+
Ok(())
87+
}
88+
89+
/// Strip `<our_did>#` from `to` and return the bare fragment, if present.
90+
fn extract_fragment<'a>(to: &'a str, our_did: &str) -> Option<&'a str> {
91+
let prefix = format!("{our_did}#");
92+
to.strip_prefix(prefix.as_str())
93+
}

src/main.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod crud;
44
mod entity;
55
mod eventloop;
66
mod i18n;
7+
mod inbox;
78
mod ipfs;
89
mod kubo;
910
mod manifest;
@@ -23,7 +24,7 @@ use cid::Cid;
2324
use clap::Parser;
2425
use ma_core::config::{Config, MaArgs};
2526
use ma_core::ipfs::IpfsDidPublisher;
26-
use ma_core::{ipns_from_secret, Ipld, IPFS_PROTOCOL_ID};
27+
use ma_core::{ipns_from_secret, Ipld, INBOX_PROTOCOL_ID, IPFS_PROTOCOL_ID};
2728
use std::net::SocketAddr;
2829
use std::path::PathBuf;
2930
use std::sync::Arc;
@@ -224,6 +225,7 @@ async fn main() -> Result<()> {
224225
}
225226
let mut endpoint = ma_core::new_ma_endpoint(secrets.iroh_secret_key, ipv6_enabled).await?;
226227
let rpc_messages = endpoint.service(rpc::RPC_PROTOCOL_ID);
228+
let inbox_messages = endpoint.service(INBOX_PROTOCOL_ID);
227229
let ipfs_messages = if ipfs_publisher_enabled {
228230
Some(endpoint.service(IPFS_PROTOCOL_ID))
229231
} else {
@@ -563,6 +565,7 @@ async fn main() -> Result<()> {
563565
eventloop::run(
564566
endpoint,
565567
rpc_messages,
568+
inbox_messages,
566569
crud_messages,
567570
ipfs_state,
568571
envelope_tx,

src/plugin/backend.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ host_fn!(ma_reply_fn(user_data: OutboxCtx; input: Vec<u8>) -> Vec<u8> {
9191
let envelope = SendEnvelope {
9292
to: req.msg.from,
9393
content_type: req.content_type,
94+
message_type: None,
9495
content: req.content,
9596
reply_to: Some(req.msg.id),
9697
};
@@ -211,8 +212,8 @@ host_fn!(ma_avatar_id_fn(user_data: AvatarIdCtx; input: Vec<u8>) -> Vec<u8> {
211212

212213
// ── ma_call host function ─────────────────────────────────────────────────────────────────────────
213214

214-
/// Hard cap on any single Wasm export invocation (init / handle_cast /
215-
/// handle_call). Enforced by extism via wasmtime epoch interruption — a
215+
/// Hard cap on any single Wasm export invocation (init / `handle_cast` /
216+
/// `handle_call`). Enforced by extism via wasmtime epoch interruption — a
216217
/// plugin stuck in an infinite loop gets aborted and the worker thread
217218
/// survives, instead of being wedged forever.
218219
pub(super) const WASM_CALL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);

src/plugin/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ impl EntityPlugin {
381381
mod wasm_repro {
382382
//! Local reproduction harness for plugin WASM crashes.
383383
//! Requires a local Kubo node with the plugin CID pinned.
384-
//! Run: cargo test wasm_repro -- --ignored --nocapture
384+
//! Run: cargo test `wasm_repro` -- --ignored --nocapture
385385
386386
use std::collections::BTreeMap;
387387

src/rpc.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ async fn handle_entity_plugin_message(
332332
crate::entity::SendEnvelope {
333333
to: format!("{}#{}", ctx.our_did, req.parent),
334334
content_type: ma_core::CONTENT_TYPE_TERM.to_string(),
335+
message_type: None,
335336
content: err_content,
336337
reply_to: None,
337338
},

0 commit comments

Comments
 (0)