diff --git a/CHANGELOG.md b/CHANGELOG.md index 55a1897..28f397e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4e33b0e..78a5e14 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/runa-runtime/src/lib.rs b/crates/runa-runtime/src/lib.rs index efc500f..3343e1d 100644 --- a/crates/runa-runtime/src/lib.rs +++ b/crates/runa-runtime/src/lib.rs @@ -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::::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(); diff --git a/docs/architecture.md b/docs/architecture.md index 38dcac0..1d0e039 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/runtime.md b/docs/runtime.md index a30857e..df56a26 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -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 |