Skip to content

Commit b626188

Browse files
olwangclaude
andcommitted
Merge feat/message: cross-isolate message channel (Channel.message<T>)
Implements the message-system core: a statically-verified cross-isolate-transferable payload contract on Channel.message<T> over the existing channel runtime, holding VM<->compiled parity. Realizes the "message is the right choice for a solo isolate" direction. Developed in a git worktree, merged once fully working (full sweep + clippy green). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 parents 5f2fbb7 + b261e82 commit b626188

13 files changed

Lines changed: 185 additions & 26 deletions

File tree

crates/rsscript/src/checks/calls.rs

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1289,6 +1289,14 @@ fn check_call_args(
12891289
&type_param_substitutions,
12901290
call_span,
12911291
);
1292+
check_message_channel_payload(
1293+
analyzer,
1294+
function,
1295+
&call_name,
1296+
&signature,
1297+
&type_param_substitutions,
1298+
call_span,
1299+
);
12921300

12931301
for (index, arg) in args.iter().enumerate() {
12941302
let Some(name) = arg
@@ -1431,6 +1439,62 @@ fn positional_param_name<'a>(
14311439
signature_params.get(index).map(|param| param.name.as_str())
14321440
}
14331441

1442+
/// Enforce the cross-isolate **message** payload contract on `Channel.message<T>`:
1443+
/// the element type must be cross-isolate-transferable (a self-contained value
1444+
/// with no managed handle), so a message can cross an isolate boundary without
1445+
/// sharing mutable state (spec §20.2-3). Skips a still-generic element (an
1446+
/// enclosing-function type param), which can't be proven here.
1447+
fn check_message_channel_payload(
1448+
analyzer: &mut Analyzer<'_>,
1449+
function: &FunctionDecl,
1450+
call_name: &str,
1451+
signature: &FunctionSig,
1452+
substitutions: &HashMap<String, String>,
1453+
call_span: &Span,
1454+
) {
1455+
// `call_name` carries explicit type args (e.g. `Channel.message<List<Int>>`);
1456+
// compare on the root.
1457+
if call_name.split('<').next() != Some("Channel.message") {
1458+
return;
1459+
}
1460+
let Some(param) = signature.type_params.first() else {
1461+
return;
1462+
};
1463+
let Some(element) = substitutions.get(param) else {
1464+
return;
1465+
};
1466+
// A bare enclosing-function type param can't be checked without a `Sendable`
1467+
// bound; leave it (a future bound would enforce it at the caller).
1468+
if function
1469+
.type_params
1470+
.iter()
1471+
.any(|type_param| type_param.name == *element)
1472+
{
1473+
return;
1474+
}
1475+
if crate::checks::local::is_cross_isolate_transferable(element) {
1476+
return;
1477+
}
1478+
analyzer.diagnostics.push(
1479+
Diagnostic::error(
1480+
code::MESSAGE_PAYLOAD_NOT_TRANSFERABLE,
1481+
format!("message channel payload `{element}` is not cross-isolate-transferable."),
1482+
call_span.clone(),
1483+
"non-transferable message payload",
1484+
)
1485+
.with_cause(
1486+
"A message must be self-contained data with no managed handle, so it can cross an isolate boundary without sharing mutable state. v1 allows Copy scalars, `String`, and `Bytes`.",
1487+
)
1488+
.with_fix(
1489+
"use_transferable_message_payload",
1490+
format!(
1491+
"Send a transferable value (a Copy scalar, `String`, or `Bytes`) instead of `{element}`, or use `Channel.bounded` for an in-isolate channel."
1492+
),
1493+
"manual",
1494+
),
1495+
);
1496+
}
1497+
14341498
fn check_generic_call_bounds(
14351499
analyzer: &mut Analyzer<'_>,
14361500
function: &FunctionDecl,
@@ -4509,7 +4573,11 @@ fn callee_display(callee: &Callee) -> String {
45094573
receiver,
45104574
method,
45114575
effect,
4512-
} => format!("{} {}.{method}", (*effect).unwrap_or(DataEffect::Read).as_str(), call_expr_label(receiver)),
4576+
} => format!(
4577+
"{} {}.{method}",
4578+
(*effect).unwrap_or(DataEffect::Read).as_str(),
4579+
call_expr_label(receiver)
4580+
),
45134581
}
45144582
}
45154583

crates/rsscript/src/checks/local.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ use std::collections::{HashMap, HashSet, VecDeque};
44
use crate::diagnostic::Span;
55
use crate::hir::{
66
CallResolution, HirBinding, HirBindingKind, HirBlock, HirCallArg, HirEffectEvent,
7-
HirEffectEventKind, HirExpr, HirFunctionBody, HirReturnProof, HirStmt, HirTypeKind, ParamEffect,
8-
ResolvedCalleeKind,
7+
HirEffectEventKind, HirExpr, HirFunctionBody, HirReturnProof, HirStmt, HirTypeKind,
8+
ParamEffect, ResolvedCalleeKind,
99
};
1010
use crate::syntax::ast::{Callee, Expr};
1111

@@ -2608,7 +2608,7 @@ fn collect_select_local_flow(
26082608
value_handle_field: None,
26092609
fresh_from_local_source: None,
26102610
fresh_from_scrutinee: false,
2611-
fresh_from_fresh_value: false,
2611+
fresh_from_fresh_value: false,
26122612
};
26132613
let binding_node = push_pattern_binding_flow_step(steps, &arm.span, binding);
26142614
if let Some(body_entry) = arm_flow.entry {
@@ -3238,7 +3238,9 @@ fn hir_expr_is_fresh_value(value: &HirExpr) -> bool {
32383238
CallResolution::Resolved { signature, .. } => signature.returns_fresh,
32393239
_ => false,
32403240
},
3241-
HirExpr::Try { value, .. } | HirExpr::Effect { value, .. } => hir_expr_is_fresh_value(value),
3241+
HirExpr::Try { value, .. } | HirExpr::Effect { value, .. } => {
3242+
hir_expr_is_fresh_value(value)
3243+
}
32423244
_ => false,
32433245
}
32443246
}
@@ -3565,6 +3567,24 @@ impl BodyState {
35653567
}
35663568
}
35673569

3570+
/// Whether a value of `type_name` may be sent as a cross-isolate **message**
3571+
/// (spec §20.2-3): a self-contained value carrying no managed (`Rc`) handle, so it
3572+
/// can cross an isolate boundary without sharing mutable state. v1 allows Copy
3573+
/// scalars plus the immutable owned-data types `String` and `Bytes` (value
3574+
/// semantics; safe to transfer/share). Mutable/managed containers (`List`, `Map`,
3575+
/// `Buffer`), structs/sums, handles, closures, and generics are conservatively
3576+
/// rejected for now — broadening to data-only structs/containers is a follow-up.
3577+
pub(crate) fn is_cross_isolate_transferable(type_name: &str) -> bool {
3578+
let type_name = type_name.trim();
3579+
if is_copy_type_name(type_name) {
3580+
return true;
3581+
}
3582+
matches!(
3583+
type_name.strip_prefix("fresh ").unwrap_or(type_name),
3584+
"String" | "Bytes"
3585+
)
3586+
}
3587+
35683588
pub(crate) fn is_copy_type_name(type_name: &str) -> bool {
35693589
let type_name = type_name.trim();
35703590
!type_name.contains('<')

crates/rsscript/src/diagnostic.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ pub mod code {
3939
pub const INTEGER_LITERAL_OUT_OF_RANGE: &str = "RS0033";
4040
pub const UNINFERABLE_BINDING_TYPE: &str = "RS0034";
4141
pub const LOWER_NAME_CONFLICT: &str = "RS0035";
42+
pub const MESSAGE_PAYLOAD_NOT_TRANSFERABLE: &str = "RS0036";
4243
pub const FEATURE_VIOLATION: &str = "RS0101";
4344
pub const UNNAMED_ARGUMENT: &str = "RS0201";
4445
pub const MISSING_DATA_EFFECT: &str = "RS0202";

crates/rsscript/src/reg_vm/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3998,6 +3998,9 @@ impl RegLowerer<'_> {
39983998
RegIntrinsic::CancellationTokenIsCancelled
39993999
}
40004000
("Channel", "bounded") => RegIntrinsic::ChannelBounded,
4001+
// A message channel reuses the bounded-channel runtime; the
4002+
// cross-isolate payload contract is enforced at check time.
4003+
("Channel", "message") => RegIntrinsic::ChannelBounded,
40014004
("Channel", "receiver") => RegIntrinsic::ChannelReceiver,
40024005
("Channel", "sender") => RegIntrinsic::ChannelSender,
40034006
("ChannelError", "message") => RegIntrinsic::ChannelErrorMessage,

crates/rsscript/src/runtime_abi.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,9 @@ const RUNTIME_INTRINSICS: &[RuntimeIntrinsic] = &[
310310
"rsscript_runtime::cancellation_token_is_cancelled",
311311
),
312312
runtime_intrinsic("Channel", "bounded", "rsscript_runtime::channel_bounded"),
313+
// Message channel: same runtime as `bounded`; the cross-isolate payload
314+
// contract is a compile-time check, not a runtime difference.
315+
runtime_intrinsic("Channel", "message", "rsscript_runtime::channel_bounded"),
313316
runtime_intrinsic("Channel", "sender", "rsscript_runtime::channel_sender"),
314317
runtime_intrinsic("Channel", "receiver", "rsscript_runtime::channel_receiver"),
315318
runtime_intrinsic("Sender", "send", "rsscript_runtime::sender_send"),
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// expect: RS0036
2+
// A `Channel.message<T>` payload must be cross-isolate-transferable — a
3+
// self-contained value with no managed handle (spec §20.2-3). A `List<Int>` is a
4+
// managed container, so it cannot be a message; this must be rejected at check
5+
// time. (Use `Channel.bounded` for an in-isolate channel.)
6+
features: async, native, local
7+
8+
async fn main() -> Result<Unit, ChannelError> {
9+
let channel = Channel.message<List<Int>>(capacity: 4)?
10+
return Ok(Unit)
11+
}

crates/rsscript/tests/vm_eval_parity/async_concurrency.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,38 @@ async fn main() -> Result<Unit, String> {
232232
source,
233233
);
234234
}
235+
236+
#[test]
237+
fn parity_message_channel_roundtrip() {
238+
// Cross-isolate message channel (spec §20.2-3): `Channel.message<T>` requires a
239+
// cross-isolate-transferable payload and reuses the bounded-channel runtime, so
240+
// send/recv must behave identically across backends.
241+
let source = r#"
242+
features: async, native, local
243+
244+
async fn main() -> Result<Unit, ChannelError> {
245+
let mut channel: Channel<Int> = Channel.message<Int>(capacity: 1)?
246+
let mut sender: Sender<Int> = Channel.sender<Int>(channel: read channel)
247+
let receiver: Receiver<Int> = Channel.receiver<Int>(channel: mut channel)?
248+
local first = 7
249+
await Sender.send<Int>(sender: read sender, value: take first)?
250+
Sender.close<Int>(sender: mut sender)
251+
match await Receiver.recv<Int>(receiver: read receiver)? {
252+
Some(value) => {
253+
Log.write(message: read String.from_int(value: value))
254+
}
255+
None => {
256+
Log.write(message: read "recv-none")
257+
}
258+
}
259+
return Ok(Unit)
260+
}
261+
"#;
262+
common::assert_vm_eval_matches_backend_with_distinct_args_allowing_unused_mut_warning(
263+
"parity-message-channel.rss",
264+
"rsscript_parity_message_channel",
265+
source,
266+
&[],
267+
&[],
268+
);
269+
}

docs/RSScript_v0.7_Spec.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5276,7 +5276,14 @@ A. Extended async surface beyond the v0.7 MVP
52765276
- must not expose Future / Pin / Poll / Waker to RSScript users (section 14.4).
52775277
52785278
B. Cross-isolate message API with zero-copy transfer
5279-
- now in scope: committed roadmap (§20.2).
5279+
- partially implemented (§20.2-3): the **message payload contract** is enforced.
5280+
`Channel.message<T>` creates a channel whose payload `T` is statically verified
5281+
cross-isolate-transferable (a self-contained value with no managed handle; v1:
5282+
Copy scalars, `String`, `Bytes`), rejecting managed/container payloads with
5283+
`RS0036`. It reuses the bounded-channel runtime, so it runs today at parity and
5284+
is the forward-compatible transport for real isolates. Remaining: broaden the
5285+
contract to data-only structs/containers, and a structured isolate-spawn with
5286+
disjoint heaps.
52805287
- explicit typed send/receive channels between isolates.
52815288
- cross-isolate payloads are owned/Copy data or values moved with take.
52825289
- take-based move across an isolate boundary is the no-shared-alias transfer

docs/spec-todo.md

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,23 @@ model without reversing a review-first tenet. Ordered by readiness/value.
4545
dispatch. Verified at parity (`parity_capability_dynamic_dispatch`, two impls). The
4646
review map already flags dynamic dispatch (`has_dynamic_protocol_dispatch`).
4747

48-
- [ ] **Cross-isolate message API (zero-copy transfer)** (§20.1-B, §20.2-3) —
49-
_blocked on foundational runtime work; design recorded._ Unlike the other three
50-
roadmap items (which were largely already built), this has **no foundation**: the
51-
runtime is strictly single-isolate (one cooperative-task heap; the channel is an
52-
explicit single-isolate `Rc<RefCell>` MPSC), so "managed handles never cross
53-
isolates" has no boundary to be enforced at yet — exactly why the spec gates it on
54-
"the isolate model maturing first." Faking it (cooperative tasks sharing a heap)
55-
would not be a real isolate boundary, so it is **not** implemented here.
56-
The smallest *sound* slice and the full feasibility analysis are recorded in
57-
[cross-isolate-design.md](cross-isolate-design.md): a static
58-
`is_cross_isolate_safe(T)` payload rule (Copy/owned, no managed handles) + a
59-
distinct `Mailbox<T>` surface over the cooperative scheduler (~1–2 weeks, holds
60-
parity). True multi-thread/multi-heap isolates are a separate multi-quarter
61-
refactor. Pick up when the mailbox surface is prioritized.
48+
- [~] **Cross-isolate message API (zero-copy transfer)** (§20.1-B, §20.2-3) —
49+
_message contract landed; multi-isolate execution still future._ The reviewable
50+
core — the **message payload contract** — is implemented: `Channel.message<T>`
51+
creates a channel whose payload `T` is statically verified
52+
**cross-isolate-transferable** (`is_cross_isolate_transferable`: a self-contained
53+
value with no managed handle — v1 allows Copy scalars, `String`, `Bytes`),
54+
rejecting managed/container payloads at check time (`RS0036`). It reuses the
55+
bounded-channel runtime, so it runs today at VM↔compiled parity
56+
(`parity_message_channel_roundtrip`) and is the forward-compatible transport for
57+
real isolates: nothing crossing it ever shares mutable state. This realizes the
58+
"message is the right choice for a solo isolate" decision — message semantics +
59+
`take` move-in, no reference-capability machinery.
60+
_Remaining (future):_ (a) broaden the payload contract to data-only structs/sums
61+
and owned containers via deep-snapshot; (b) a structured isolate-spawn so two
62+
isolates actually run with disjoint heaps. True multi-thread/multi-heap isolates
63+
are a separate multi-quarter refactor. Full plan in
64+
[cross-isolate-design.md](cross-isolate-design.md).
6265

6366
## Removed — non-goals (not deferred; deleted from the roadmap)
6467

packages/async-runtime/rsspkg.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ version = 1
44
name = "rss-async-runtime"
55
version = "0.1.0"
66
source = "path+packages/async-runtime"
7-
checksum = "sha256:ef2db23f07d3b0d04a351db0503214732227add443067bff3c3b6355bb2cb7b9"
7+
checksum = "sha256:2483b7a760be54a48fdfab90a53bfda1514447fe59ceb1b18bb6964e829324f0"
88
interface_hash = "sha256:36e6897d97ac40f70744171504605f1905c435cb09b5957a6dbd3b3f47852a13"
9-
review_hash = "sha256:bbd78c597740f5e9eb2c0ba82244e8d9ce9ef5e1522131eeb3e4efd1a541cc59"
9+
review_hash = "sha256:5583274ea0013bc488504659a062fdf99330af2a84a247fb2f5694bbe8f2d9b2"
1010
features = []
1111

1212
[metadata]

0 commit comments

Comments
 (0)