feat!: retain actor state when handlers return unit

Allow omitted state returns in handlers and shutdown hooks. Preserve committed state rather than invocation-local mutations, while acknowledging successful messages and committing valid sends.

BREAKING CHANGE: unit returns no longer replace actor state with null. Use host state editing to explicitly set null state.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Steve Biedermann 2026-11-03 19:00:18 +01:00
parent d0a195e9f5
commit 867eabb957
5 changed files with 113 additions and 5 deletions

View File

@ -6,6 +6,9 @@ All notable changes to this project will be documented in this file.
### Changed
- **Breaking:** Handler and shutdown-hook unit returns (including omitted returns)
now retain the previously committed state instead of replacing it with null.
Draft mutations are discarded; valid sends and message acknowledgement still commit.
- Change the bundled counter's `send` operation to `start`, which queues an
increment to itself using `ctx.this`.
- **Breaking:** Protocol v5 adds actor log thresholds to snapshots, guarded

View File

@ -156,6 +156,11 @@ for Rune conversions and value limits. Protocol version 5 uses
`handle(ctx, state, sender, message) -> new_state`; `ctx.send(recipient, message)`
records sends with the calling actor as sender, and
`ctx.send_from(sender, recipient, message)` supports forwarding.
Returning state is optional: an omitted return, `return;`, or `()` retains the
previously committed state, discarding invocation-local state mutations.
Valid sends still commit and the message is acknowledged. The same rule applies
to `on_shutdown(state)`. To replace state, return a non-unit portable value.
Unit can no longer be used to reset state to null; use host state editing instead.
Sender metadata is separate from message data; host-originated messages have
unit/null sender. System messages are trusted runtime commands, not data an
actor can construct.

View File

@ -451,7 +451,11 @@ impl Runtime {
}
fn commit(&mut self, id: &ActorRef, state: rune::Value) -> ExperimentResult<()> {
let state = value_from_rune(&state)?;
let state = if state.into_unit().is_ok() {
None
} else {
Some(value_from_rune(&state)?)
};
let effects = self.actors[id].context.take_effects();
let mut counts = BTreeMap::<ActorRef, usize>::new();
for effect in &effects {
@ -473,7 +477,9 @@ impl Runtime {
}
}
let actor = self.actors.get_mut(id).expect("scheduled actor exists");
actor.snapshot.state = state;
if let Some(state) = state {
actor.snapshot.state = state;
}
actor.snapshot.reserved = None;
actor.snapshot.status = Status::Idle;
actor.active = None;
@ -600,6 +606,89 @@ mod tests {
use super::*;
use runa_core::MAX_VALUE_NODES;
#[test]
fn optional_state_retains_committed_state_and_commits_sends() -> ExperimentResult<()> {
let mut runtime = Runtime::default();
let implementation = publish(
&mut runtime,
r#"pub fn handle(ctx, state, sender, message) {
state.value = 999;
if message == 1 { ctx.send(ctx.this, 2); return; }
if message == 2 { return (); }
#{value: 7}
}
pub fn on_shutdown(state) { state.value = 1000; }"#,
)?;
let initial = Value::Record(BTreeMap::from([("value".to_owned(), Value::Integer(3))]));
let actor = runtime.spawn(implementation, initial.clone(), capacity(4))?;
runtime.send(actor, Value::Integer(1))?;
for _ in 0..50 {
runtime.tick(capacity(1));
}
let snapshot = runtime.inspect(actor)?;
assert_eq!(snapshot.state, initial);
assert_eq!(snapshot.status, Status::Idle);
assert!(snapshot.reserved.is_none());
assert!(snapshot.queued.is_empty());
assert_eq!(
runtime
.drain_events()
.iter()
.filter(|event| matches!(event, Event::Committed(id) if *id == actor))
.count(),
2
);
runtime.send(actor, Value::Integer(3))?;
for _ in 0..50 {
runtime.tick(capacity(32));
}
assert_eq!(
runtime.inspect(actor)?.state,
Value::Record(BTreeMap::from([("value".to_owned(), Value::Integer(7))]))
);
runtime.stop(actor)?;
for _ in 0..50 {
runtime.tick(capacity(32));
}
assert_eq!(runtime.inspect(actor)?.status, Status::Stopped);
assert_eq!(
runtime.inspect(actor)?.state,
Value::Record(BTreeMap::from([("value".to_owned(), Value::Integer(7))]))
);
Ok(())
}
#[test]
fn omitted_state_still_validates_effects_and_handles_null_state() -> ExperimentResult<()> {
let mut runtime = Runtime::default();
let implementation = publish(
&mut runtime,
"pub fn handle(ctx, state, sender, message) { ctx.send(message, 1); }",
)?;
let actor = runtime.spawn(implementation, Value::Integer(5), capacity(2))?;
runtime.send(actor, Value::ActorRef(ActorRef::new()))?;
for _ in 0..20 {
runtime.tick(capacity(32));
}
let snapshot = runtime.inspect(actor)?;
assert_eq!(snapshot.state, Value::Integer(5));
assert!(matches!(snapshot.status, Status::Failed(_)));
assert!(snapshot.reserved.is_some());
let implementation = publish(
&mut runtime,
"pub fn handle(ctx, state, sender, message) {}",
)?;
let actor = runtime.spawn(implementation, Value::Null, capacity(2))?;
runtime.send(actor, Value::Null)?;
for _ in 0..10 {
runtime.tick(capacity(32));
}
assert_eq!(runtime.inspect(actor)?.state, Value::Null);
assert_eq!(runtime.inspect(actor)?.status, Status::Idle);
assert!(runtime.inspect(actor)?.reserved.is_none());
Ok(())
}
#[test]
fn editing_is_guarded_atomic_and_can_repair_a_failed_reservation() -> ExperimentResult<()> {
let mut runtime = Runtime::default();

View File

@ -100,7 +100,7 @@ Start with bounded queues and observable backpressure. For durable mailboxes, in
### 4.3 State
State is owned by the actor runtime, logically private, and supplied to each scheduled handler invocation. The handler works with a controlled draft or value and returns new state; it cannot mutate committed state directly. State has a schema/version where useful and can be inspected via snapshots at safe points.
State is owned by the actor runtime, logically private, and supplied to each scheduled handler invocation. The handler works with a controlled draft or value and optionally returns new state; it cannot mutate committed state directly. Returning unit or omitting a return retains the previous committed state and discards draft mutations. State has a schema/version where useful and can be inspected via snapshots at safe points.
### 4.4 Stateless handler
@ -479,7 +479,8 @@ explicit host-provided migration closure for structured actor state.
Actor snapshots expose active VM instruction progress, instruction pointer,
and call-frame depth. State and messages are owned portable trees of null,
booleans, integers, strings, lists, records, and distinct actor references.
Rune handlers receive `(ctx, state, sender, message)` and return only state;
Rune handlers receive `(ctx, state, sender, message)` and optionally return state;
unit returns retain committed state and discard invocation-local draft mutations.
context sends are buffered and committed atomically with state.
Rune heap objects never become shared committed actor data. State schemas,
version compatibility checks, Rune-authored migration functions, source

View File

@ -222,6 +222,14 @@ handle(ctx, state, sender, message) -> new_state
on_shutdown(state) -> new_state // optional
```
Both functions may omit the returned state. A unit result (`()`, `return;`,
or falling through without a value) keeps the previously committed state,
not the invocation's mutable draft. Non-unit results replace state as before.
Successful handlers still acknowledge the reserved message and commit validated
context sends atomically. Invalid sends still fail the transition. A unit
shutdown result stops the actor without changing its state. Unit cannot reset
state to null anymore; host state editing can still set null explicitly.
The runtime uses internal envelopes containing sender and data, then passes
them separately to the handler. Host-originated messages have unit/null sender.
Actors choose their own message data. For example:
@ -289,7 +297,8 @@ System messages are host-only commands, separate from ordinary data messages:
stop, kill, restart, pause, resume, reload, and become. A record containing
`kind: "stop"` sent by an actor is just application data.
Graceful stop finishes the active handler, then runs optional `on_shutdown(state)`
with cooperative instruction slices and commits its returned state. It does not
with cooperative instruction slices and commits a non-unit returned state,
or retains committed state for a unit return. It does not
drain queued input. A missing hook stops normally; a failing hook marks the actor
failed and retains its last committed state. Stop unpauses a paused actor so
shutdown can progress; explicit scheduler ticks are still needed.
@ -308,6 +317,7 @@ resolves it; there is no automatic poison-message policy or supervision.
| Situation | State | Reserved input | Effects | Status |
| --- | --- | --- | --- | --- |
| Handler returns a valid transition | New state commits | Acknowledged | All validated sends enqueue | `Idle` |
| Handler returns unit / no state | Last committed state retained; draft discarded | Acknowledged | All validated sends enqueue | `Idle` |
| Rune execution or transition decoding fails | Last committed state retained | Retained | None from the returned transition | `Failed` |
| Any target is unknown, stopped, failed, or over capacity | Last committed state retained | Retained | No sends from the transition enqueue | `Failed` |
| Graceful stop | Active handler then shutdown state commits | Active input acknowledged | Valid active-handler sends commit | `Stopped` after shutdown |