Skip to content

Commit 010c09e

Browse files
committed
feat(vm): expose cancellable invocation item streams
1 parent 7ca7a27 commit 010c09e

21 files changed

Lines changed: 2026 additions & 600 deletions

crates/rustscript/tests/alias_smoke.rs

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,17 +27,30 @@ fn alias_exports_op_code() {
2727

2828
#[cfg(feature = "runtime")]
2929
#[test]
30-
fn alias_exports_public_runtime_event_contract() {
31-
fn accept_sink<S: rustscript::EventSink>(_sink: S) {}
32-
33-
struct Sink;
34-
impl rustscript::EventSink for Sink {
35-
fn emit(&mut self, _payload: rustscript::EventPayload) -> rustscript::RuntimeResult<()> {
36-
Ok(())
37-
}
38-
}
30+
fn alias_exports_public_invocation_stream_contract() {
31+
fn accept_item(_item: rustscript::InvocationItem) {}
32+
33+
accept_item(rustscript::InvocationItem::Complete(
34+
rustscript::Value::Null,
35+
));
36+
accept_item(rustscript::InvocationItem::Event(rustscript::Value::Bool(
37+
true,
38+
)));
39+
40+
fn accept_poll(_poll: rustscript::InvocationPoll) {}
41+
accept_poll(rustscript::InvocationPoll::Pending);
42+
accept_poll(rustscript::InvocationPoll::Ready(None));
43+
accept_poll(rustscript::InvocationPoll::Ready(Some(Ok(
44+
rustscript::InvocationItem::Complete(rustscript::Value::Null),
45+
))));
3946

40-
accept_sink(Sink);
47+
fn accept_error(_error: rustscript::InvocationError) {}
48+
accept_error(rustscript::InvocationError::Cancelled(
49+
rustscript::CancellationReason::Requested,
50+
));
51+
accept_error(rustscript::InvocationError::Host {
52+
message: "boom".to_string(),
53+
});
4154
}
4255

4356
#[cfg(feature = "sqlite")]

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, 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; 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 `CancellationReason`, 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: 24 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
1-
use super::error::{RuntimeError, RuntimeErrorCode, RuntimeResult};
2-
use super::event::{EventEmitter, EventLimits, EventReceipt, EventSink};
3-
use crate::vm::{Value, VmResult};
1+
use super::error::RuntimeResult;
2+
use super::event::EventLimits;
43

5-
pub const RUNTIME_INPUT_NAME: &str = "runtime::input";
64
#[allow(dead_code)]
7-
pub const RUNTIME_EMIT_NAME: &str = "runtime::emit";
5+
pub const STREAM_EMIT_NAME: &str = "stream::emit";
86

9-
#[allow(dead_code)]
10-
pub type RuntimeEventSink = dyn EventSink;
11-
12-
/// Configuration for one VM/run-scoped generic runtime context.
7+
/// Configuration for one VM/run-scoped invocation stream.
138
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
149
pub struct RuntimeContextConfig {
1510
event_limits: EventLimits,
@@ -31,74 +26,29 @@ impl Default for RuntimeContextConfig {
3126
}
3227
}
3328

34-
/// Run-scoped input and generic event transport hooks.
29+
/// Run-scoped invocation stream configuration.
3530
///
36-
/// The context stores values as VM [`Value`]s and delegates event persistence/delivery to the
37-
/// embedding. It has no knowledge of sessions, providers, platforms, or event names.
31+
/// The context carries only the per-item event bound. Event values are owned by
32+
/// the active invocation's single pending-event slot; there is no ambient
33+
/// input, no embedding event sink, and no sequence or persistence policy here.
3834
pub struct RuntimeContext {
39-
input: Option<Value>,
40-
events: EventEmitter,
35+
event_limits: EventLimits,
4136
}
4237

4338
#[allow(dead_code)]
4439
impl RuntimeContext {
4540
pub fn with_config(config: RuntimeContextConfig) -> RuntimeResult<Self> {
4641
Ok(Self {
47-
input: None,
48-
events: EventEmitter::new(config.event_limits()),
42+
event_limits: config.event_limits(),
4943
})
5044
}
5145

5246
pub fn config(&self) -> RuntimeContextConfig {
53-
RuntimeContextConfig::new(self.events.limits())
54-
}
55-
56-
pub fn set_input(&mut self, value: Value) -> RuntimeResult<()> {
57-
self.input = Some(value);
58-
Ok(())
59-
}
60-
61-
pub fn clear_input(&mut self) {
62-
self.input = None;
63-
}
64-
65-
pub fn reset_for_reuse(&mut self) {
66-
self.input = None;
67-
self.events.reset_for_reuse();
68-
}
69-
70-
pub fn input(&self) -> RuntimeResult<Value> {
71-
self.input.clone().ok_or_else(|| {
72-
RuntimeError::new(
73-
RuntimeErrorCode::InputUnavailable,
74-
RUNTIME_INPUT_NAME,
75-
"run input has not been configured",
76-
)
77-
})
78-
}
79-
80-
pub fn set_event_sink<S>(&mut self, sink: S) -> RuntimeResult<()>
81-
where
82-
S: EventSink + 'static,
83-
{
84-
self.events.set_sink(sink);
85-
Ok(())
86-
}
87-
88-
pub fn clear_event_sink(&mut self) {
89-
self.events.clear_sink();
90-
}
91-
92-
pub fn emit(&mut self, value: Value) -> RuntimeResult<EventReceipt> {
93-
self.events.emit(value)
94-
}
95-
96-
pub fn emitted_events(&self) -> u64 {
97-
self.events.emitted_events()
47+
RuntimeContextConfig::new(self.event_limits)
9848
}
9949

10050
pub fn event_limits(&self) -> EventLimits {
101-
self.events.limits()
51+
self.event_limits
10252
}
10353
}
10454

@@ -109,29 +59,22 @@ impl Default for RuntimeContext {
10959
}
11060
}
11161

112-
/// Parent registration helper for the zero-argument `runtime::input()` host function.
113-
pub fn runtime_input(context: &RuntimeContext) -> VmResult<Value> {
114-
context
115-
.input()
116-
.map_err(|error| crate::vm::VmError::HostError(error.to_string()))
117-
}
118-
119-
/// Parent registration helper for the one-argument `runtime::emit(value)` host function.
120-
pub fn runtime_emit(context: &mut RuntimeContext, value: Value) -> VmResult<()> {
121-
context
122-
.emit(value)
123-
.map(|_| ())
124-
.map_err(|error| crate::vm::VmError::HostError(error.to_string()))
125-
}
126-
12762
#[cfg(test)]
12863
mod tests {
129-
use super::{RUNTIME_EMIT_NAME, RUNTIME_INPUT_NAME, RuntimeContext};
64+
use super::{EventLimits, RuntimeContext, RuntimeContextConfig, STREAM_EMIT_NAME};
13065

13166
#[test]
132-
fn host_names_are_generic_and_stable() {
133-
assert_eq!(RUNTIME_INPUT_NAME, "runtime::input");
134-
assert_eq!(RUNTIME_EMIT_NAME, "runtime::emit");
67+
fn host_name_is_generic_and_stable() {
68+
assert_eq!(STREAM_EMIT_NAME, "stream::emit");
13569
assert!(std::mem::size_of::<RuntimeContext>() > 0);
13670
}
71+
72+
#[test]
73+
fn per_item_event_limits_are_configurable() {
74+
let limits = EventLimits::new(128, 4).expect("limits should be valid");
75+
let context = RuntimeContext::with_config(RuntimeContextConfig::new(limits))
76+
.expect("context should be constructible");
77+
assert_eq!(context.event_limits(), limits);
78+
assert_eq!(context.config().event_limits(), limits);
79+
}
13780
}
Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,12 @@
11
use pd_host_function::pd_host_function;
22

33
use super::AnyValue;
4-
use crate::vm::{Value, Vm, VmResult};
5-
6-
/// Returns the embedding-provided input for the current run.
7-
#[pd_host_function(name = "runtime::input")]
8-
fn runtime_input_impl(vm: &mut Vm) -> VmResult<AnyValue> {
9-
vm.runtime_input_value()
10-
}
11-
12-
/// Returns the run-scoped input encoded with the runtime's strict JSON contract.
13-
#[pd_host_function(name = "runtime::input_json")]
14-
fn runtime_input_json_impl(vm: &mut Vm) -> VmResult<String> {
15-
let value = vm.runtime_input_value()?;
16-
super::json::encode_value_to_string(&value)
17-
}
18-
19-
/// Emits one bounded event without changing the script return value.
20-
#[pd_host_function(name = "runtime::emit")]
21-
fn runtime_emit_impl(vm: &mut Vm, value: AnyValue) -> VmResult<()> {
22-
vm.emit_runtime_event(value)
23-
}
24-
25-
/// Emits one JSON text event for strict RSS boundary adapters.
26-
#[pd_host_function(name = "runtime::emit_json")]
27-
fn runtime_emit_json_impl(vm: &mut Vm, value: &str) -> VmResult<()> {
28-
vm.emit_runtime_event(Value::string(value))
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)
2912
}

src/builtins/runtime/error.rs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,8 @@ pub type RuntimeResult<T> = Result<T, RuntimeError>;
88
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99
pub enum RuntimeErrorCode {
1010
InvalidConfiguration,
11-
InputUnavailable,
12-
EventSinkUnavailable,
1311
EventPayloadTooLarge,
1412
EventDepthExceeded,
15-
EventSequenceExhausted,
16-
EventSinkRejected,
1713
ResourceLimitExceeded,
1814
InvalidResourceHandle,
1915
ResourceHandleWrongTable,
@@ -35,12 +31,8 @@ impl RuntimeErrorCode {
3531
pub const fn as_str(self) -> &'static str {
3632
match self {
3733
Self::InvalidConfiguration => "invalid_configuration",
38-
Self::InputUnavailable => "input_unavailable",
39-
Self::EventSinkUnavailable => "event_sink_unavailable",
4034
Self::EventPayloadTooLarge => "event_payload_too_large",
4135
Self::EventDepthExceeded => "event_depth_exceeded",
42-
Self::EventSequenceExhausted => "event_sequence_exhausted",
43-
Self::EventSinkRejected => "event_sink_rejected",
4436
Self::ResourceLimitExceeded => "resource_limit_exceeded",
4537
Self::InvalidResourceHandle => "invalid_resource_handle",
4638
Self::ResourceHandleWrongTable => "resource_handle_wrong_table",
@@ -150,14 +142,14 @@ mod tests {
150142
fn structured_error_preserves_code_and_fields() {
151143
let error = RuntimeError::new(
152144
RuntimeErrorCode::EventPayloadTooLarge,
153-
"runtime::emit",
145+
"stream::emit",
154146
"event payload exceeds the configured bound",
155147
)
156148
.with_limit(32)
157149
.with_value(64);
158150

159151
assert_eq!(error.code(), RuntimeErrorCode::EventPayloadTooLarge);
160-
assert_eq!(error.operation(), "runtime::emit");
152+
assert_eq!(error.operation(), "stream::emit");
161153
assert_eq!(error.limit(), Some(32));
162154
assert_eq!(error.value(), Some(64));
163155
assert!(error.to_string().contains("event_payload_too_large"));

0 commit comments

Comments
 (0)