Skip to content

Commit 2f6fa98

Browse files
committed
feat(vm): expose cancellable invocation item streams
1 parent db543da commit 2f6fa98

20 files changed

Lines changed: 2144 additions & 19 deletions

build.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,18 @@ fn main() {
166166
catalog.retain(|entry| !entry.source_name.starts_with("sqlite::"));
167167
}
168168

169-
let host_sources = [SourceSpec {
170-
path: "src/builtins/runtime/host.rs".to_string(),
171-
module: "host".to_string(),
172-
category: SourceCategory::DefaultHost,
173-
}];
169+
let host_sources = vec![
170+
SourceSpec {
171+
path: "src/builtins/runtime/host.rs".to_string(),
172+
module: "host".to_string(),
173+
category: SourceCategory::DefaultHost,
174+
},
175+
SourceSpec {
176+
path: "src/builtins/runtime/context_host.rs".to_string(),
177+
module: "context_host".to_string(),
178+
category: SourceCategory::DefaultHost,
179+
},
180+
];
174181
let builtin_sources = builtin_source_specs(&namespaces);
175182
let core_sources = [SourceSpec {
176183
path: "src/builtins/runtime/core.rs".to_string(),

crates/rustscript/tests/alias_smoke.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,31 @@ fn alias_exports_op_code() {
2121
let _ = rustscript::OpCode::Nop;
2222
let _ = rustscript::OpCode::Add;
2323
}
24+
25+
#[cfg(feature = "runtime")]
26+
#[test]
27+
fn alias_exports_public_invocation_stream_contract() {
28+
fn accept_item(_item: rustscript::InvocationItem) {}
29+
30+
accept_item(rustscript::InvocationItem::Complete(
31+
rustscript::Value::Null,
32+
));
33+
accept_item(rustscript::InvocationItem::Event(rustscript::Value::Bool(
34+
true,
35+
)));
36+
37+
fn accept_poll(_poll: rustscript::InvocationPoll) {}
38+
accept_poll(rustscript::InvocationPoll::Pending);
39+
accept_poll(rustscript::InvocationPoll::Ready(None));
40+
accept_poll(rustscript::InvocationPoll::Ready(Some(Ok(
41+
rustscript::InvocationItem::Complete(rustscript::Value::Null),
42+
))));
43+
44+
fn accept_error(_error: rustscript::InvocationError) {}
45+
accept_error(rustscript::InvocationError::Cancelled(
46+
rustscript::operation::OperationCancelReason::Requested,
47+
));
48+
accept_error(rustscript::InvocationError::Host {
49+
message: "boom".to_string(),
50+
});
51+
}

docs/callable-runtime.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@ Reset clears Program runtime values and rebinds root function items from Program
4343

4444
PDRC recordings preserve full execution-frame metadata. Callable environments use identity-table encoding, so aliases still share one environment after decode.
4545

46+
## Invocation item stream
47+
48+
`Vm::start_invocation` starts one exported callable with ordinary `Value` arguments and returns an `Invocation` handle that behaves like a fused `Stream<Item = Result<InvocationItem, InvocationError>>`:
49+
50+
- `InvocationItem::Event(value)` items arrive in order for each `stream::emit(value)` call; `stream::emit` still evaluates to `()` inside RSS.
51+
- exactly one `InvocationItem::Complete(value)` carries the callable return value; events never replace it;
52+
- cancellation, fuel exhaustion, epoch deadline expiry, runtime capability failures (including event payload bound violations), and host failures each produce exactly one typed `InvocationError` item;
53+
- every poll after `Complete` or the error item returns `Ready(None)` (fused end of stream);
54+
- `InvocationPoll::Pending` means the VM is paused on an outstanding host operation; drive it through the embedding-owned async bridge and poll again.
55+
56+
Polling drives execution and provides backpressure: at most one event item is buffered between polls, and the VM does not produce items while the consumer is not polling. `stream::emit` validates only the configured per-item value bound (payload bytes and nesting depth); sequence assignment, receipts, persistence, and delivery policy belong to the embedding. At most one invocation is active per VM, `Invocation::cancel(reason)` cancels with a typed `OperationCancelReason`, dropping the handle retires the invocation synchronously for immediate VM reuse, and the low-level `Vm::run` pump is unchanged for custom drivers.
57+
4658
## Optimized backends
4759

4860
Whole-program AOT and Trace JIT use the same builtin call path (static catalog IDs) for environment binding and native frame dispatch for `callvalue`. Script-frame entry and return preserve frame-relative locals and typed continuations.

src/builtins/runtime/context.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//! Run-scoped invocation stream configuration.
2+
//!
3+
//! The [`RuntimeContext`] carries only the per-item event bound applied by
4+
//! `stream::emit`. Event values are owned by the active invocation's single
5+
//! pending-event slot; there is no ambient input, no embedding event sink, and
6+
//! no sequence or persistence policy here.
7+
8+
use super::error::RuntimeResult;
9+
use super::event::EventLimits;
10+
11+
/// The authoritative `stream::emit` builtin identity.
12+
#[allow(dead_code)]
13+
pub const STREAM_EMIT_NAME: &str = "stream::emit";
14+
15+
/// Configuration for one VM/run-scoped invocation stream.
16+
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17+
pub struct RuntimeContextConfig {
18+
event_limits: EventLimits,
19+
}
20+
21+
#[allow(dead_code)]
22+
impl RuntimeContextConfig {
23+
pub fn new(event_limits: EventLimits) -> Self {
24+
Self { event_limits }
25+
}
26+
27+
#[allow(dead_code)]
28+
pub const fn event_limits(self) -> EventLimits {
29+
self.event_limits
30+
}
31+
}
32+
33+
/// Run-scoped invocation stream configuration.
34+
#[derive(Debug, Default)]
35+
pub struct RuntimeContext {
36+
event_limits: EventLimits,
37+
}
38+
39+
#[allow(dead_code)]
40+
impl RuntimeContext {
41+
pub fn with_config(config: RuntimeContextConfig) -> RuntimeResult<Self> {
42+
Ok(Self {
43+
event_limits: config.event_limits,
44+
})
45+
}
46+
47+
pub fn config(&self) -> RuntimeContextConfig {
48+
RuntimeContextConfig::new(self.event_limits)
49+
}
50+
51+
pub fn event_limits(&self) -> EventLimits {
52+
self.event_limits
53+
}
54+
}
55+
56+
#[cfg(test)]
57+
mod tests {
58+
use super::{EventLimits, RuntimeContext, RuntimeContextConfig, STREAM_EMIT_NAME};
59+
60+
#[test]
61+
fn host_name_is_generic_and_stable() {
62+
assert_eq!(STREAM_EMIT_NAME, "stream::emit");
63+
assert!(std::mem::size_of::<RuntimeContext>() > 0);
64+
}
65+
66+
#[test]
67+
fn per_item_event_limits_are_configurable() {
68+
let limits = EventLimits::new(128, 4).expect("limits should be valid");
69+
let context = RuntimeContext::with_config(RuntimeContextConfig::new(limits))
70+
.expect("context should be constructible");
71+
assert_eq!(context.event_limits(), limits);
72+
assert_eq!(context.config().event_limits(), limits);
73+
}
74+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
use pd_host_function::pd_host_function;
2+
3+
use super::AnyValue;
4+
use crate::vm::{CallOutcome, Vm, VmResult};
5+
6+
/// Places one bounded event item on the active invocation stream and yields
7+
/// control to the invocation poller. `stream::emit` still evaluates to `()`
8+
/// inside RSS.
9+
#[pd_host_function(name = "stream::emit")]
10+
fn stream_emit_impl(vm: &mut Vm, value: AnyValue) -> VmResult<CallOutcome> {
11+
vm.emit_stream_item(value)
12+
}

src/builtins/runtime/error.rs

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
//! Structured runtime error types shared by the invocation stream.
2+
//!
3+
//! A [`RuntimeError`] carries a stable machine-readable [`RuntimeErrorCode`],
4+
//! the offending builtin operation name, and optional numeric limit/value
5+
//! fields. The invocation stream preserves these instead of flattening them
6+
//! to a string, so an embedding can branch on the code and inspect the
7+
//! numeric state (payload bytes, depth) without string matching.
8+
9+
use std::fmt;
10+
11+
/// Result alias used by runtime builtin surfaces.
12+
pub type RuntimeResult<T> = Result<T, RuntimeError>;
13+
14+
/// Stable machine-readable runtime error codes.
15+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16+
pub enum RuntimeErrorCode {
17+
InvalidConfiguration,
18+
EventPayloadTooLarge,
19+
EventDepthExceeded,
20+
ResourceLimitExceeded,
21+
InvalidResourceHandle,
22+
ResourceHandleWrongTable,
23+
OperationFailed,
24+
OperationAlreadyTerminal,
25+
OperationCancelled,
26+
SyncResourceUnavailable,
27+
CloseFailed,
28+
}
29+
30+
impl RuntimeErrorCode {
31+
/// Stable snake_case string form, used for transport and tests.
32+
pub const fn as_str(self) -> &'static str {
33+
match self {
34+
Self::InvalidConfiguration => "invalid_configuration",
35+
Self::EventPayloadTooLarge => "event_payload_too_large",
36+
Self::EventDepthExceeded => "event_depth_exceeded",
37+
Self::ResourceLimitExceeded => "resource_limit_exceeded",
38+
Self::InvalidResourceHandle => "invalid_resource_handle",
39+
Self::ResourceHandleWrongTable => "resource_handle_wrong_table",
40+
Self::OperationFailed => "operation_failed",
41+
Self::OperationAlreadyTerminal => "operation_already_terminal",
42+
Self::OperationCancelled => "operation_cancelled",
43+
Self::SyncResourceUnavailable => "sync_resource_unavailable",
44+
Self::CloseFailed => "close_failed",
45+
}
46+
}
47+
}
48+
49+
/// A structured runtime error with a stable code and optional numeric state.
50+
#[derive(Clone, Debug, PartialEq, Eq)]
51+
pub struct RuntimeError {
52+
code: RuntimeErrorCode,
53+
operation: String,
54+
message: String,
55+
limit: Option<u64>,
56+
value: Option<u64>,
57+
}
58+
59+
impl RuntimeError {
60+
pub fn new(code: RuntimeErrorCode, operation: &str, message: impl Into<String>) -> Self {
61+
Self {
62+
code,
63+
operation: operation.to_string(),
64+
message: message.into(),
65+
limit: None,
66+
value: None,
67+
}
68+
}
69+
70+
/// Attaches the configured bound that was violated.
71+
pub fn with_limit(mut self, limit: usize) -> Self {
72+
self.limit = Some(limit as u64);
73+
self
74+
}
75+
76+
/// Attaches the offending value (for example the measured payload size).
77+
pub fn with_value(mut self, value: usize) -> Self {
78+
self.value = Some(value as u64);
79+
self
80+
}
81+
82+
pub fn code(&self) -> RuntimeErrorCode {
83+
self.code
84+
}
85+
86+
pub fn operation(&self) -> &str {
87+
&self.operation
88+
}
89+
90+
pub fn limit(&self) -> Option<u64> {
91+
self.limit
92+
}
93+
94+
pub fn value(&self) -> Option<u64> {
95+
self.value
96+
}
97+
98+
pub fn message(&self) -> &str {
99+
&self.message
100+
}
101+
}
102+
103+
impl fmt::Display for RuntimeError {
104+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105+
write!(f, "{}: {}", self.code.as_str(), self.message)?;
106+
if let Some(limit) = self.limit {
107+
write!(f, " (limit {limit})")?;
108+
}
109+
if let Some(value) = self.value {
110+
write!(f, " (value {value})")?;
111+
}
112+
Ok(())
113+
}
114+
}
115+
116+
impl std::error::Error for RuntimeError {}
117+
118+
#[cfg(test)]
119+
mod tests {
120+
use super::{RuntimeError, RuntimeErrorCode};
121+
122+
#[test]
123+
fn structured_error_preserves_code_and_fields() {
124+
let error = RuntimeError::new(
125+
RuntimeErrorCode::EventPayloadTooLarge,
126+
"stream::emit",
127+
"event payload exceeds the configured bound",
128+
)
129+
.with_limit(32)
130+
.with_value(64);
131+
132+
assert_eq!(error.code(), RuntimeErrorCode::EventPayloadTooLarge);
133+
assert_eq!(error.operation(), "stream::emit");
134+
assert_eq!(error.limit(), Some(32));
135+
assert_eq!(error.value(), Some(64));
136+
assert!(error.to_string().contains("event_payload_too_large"));
137+
}
138+
}

0 commit comments

Comments
 (0)