Skip to content

Commit 86704cd

Browse files
committed
fix: clear effects on failed Fire and add doc caveat for pointer contexts
Signed-off-by: Joshua Temple <joshua.temple@stablekernel.com>
1 parent 69836be commit 86704cd

3 files changed

Lines changed: 69 additions & 11 deletions

File tree

state/doc.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@
5252
// Fire cannot undo is a real-world side effect a host already applied from an effect
5353
// emitted by an earlier, successful step — those are outside the kernel's pure state.
5454
//
55+
// The rollback restores the context VALUE the kernel holds, not the pointee behind a
56+
// reference context. Under a value C this is total. Under a pointer or reference C
57+
// (Machine[S, E, *Order], or a struct holding maps/slices) a reducer that mutates the
58+
// pointee IN PLACE is not unwound — rollback restores the pointer header the kernel
59+
// captured, not the data it points at. Keeping in-place mutation out of a reducer
60+
// (reducers should return new values rather than write through a shared pointer) is
61+
// the host's responsibility, the same value-context discipline the rest of this
62+
// kernel assumes.
63+
//
5564
// # The definition IR is the spec
5665
//
5766
// The canonical machine is a serializable definition IR: pure data, lossless to

state/fire.go

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,15 @@ func (i *Instance[S, E, C]) fireCore(ctx context.Context, event E) FireResult[S]
123123
if res.Err != nil {
124124
i.rollback(txn)
125125
res.NewState = i.current
126+
// Clear any effects accumulated before the failure. The single-spine commit
127+
// path already returns Effects: nil on every failure, but the parallel path
128+
// (fireParallel accumulates earlier regions' effects) and the run-to-completion
129+
// loop (it appends a sub-step's effects before checking its error) can carry
130+
// real effects of earlier, successfully-committed sub-steps. Dropping them here
131+
// makes the no-effects-on-failure contract hold uniformly regardless of which
132+
// path produced the partial effects, so a host replaying a failed Fire cannot
133+
// double-apply them.
134+
res.Effects = nil
126135
}
127136
return res
128137
}
@@ -131,7 +140,9 @@ func (i *Instance[S, E, C]) fireCore(ctx context.Context, event E) FireResult[S]
131140
// Fire can restore them, making a failed macrostep a no-op on the instance's
132141
// persisted internal state. It holds the configuration, current leaf, context,
133142
// recorded history maps, and the internal-event queue — every field a commit
134-
// writes before it can fail.
143+
// writes before it can fail. The configuration is aliased (the macrostep reassigns
144+
// i.config rather than mutating it in place); the history maps and queue are copied
145+
// only when non-empty (see beginTxn).
135146
type fireTxn[S comparable, E comparable, C any] struct {
136147
current S
137148
config []S
@@ -141,15 +152,27 @@ type fireTxn[S comparable, E comparable, C any] struct {
141152
raised []E
142153
}
143154

144-
// beginTxn snapshots the instance's macrostep-mutable state before a Fire runs. The
145-
// configuration, history maps, and queue are copied (not aliased) so an in-place
146-
// mutation during the macrostep cannot corrupt the saved snapshot; the context and
147-
// current leaf are values. It allocates only the small per-macrostep snapshot and
148-
// never touches a clock or IO, so Fire stays pure.
155+
// beginTxn snapshots the instance's macrostep-mutable state before a Fire runs. It
156+
// restores only the instance's PERSISTED state; the transient FireResult.Effects
157+
// accumulated by a failed macrostep are cleared separately in fireCore.
158+
//
159+
// The configuration slice is ALIASED, not copied: the macrostep only ever REASSIGNS
160+
// i.config to a freshly-built slice (descendToLeaves, a history restore, replaceRegionLeaf,
161+
// or a literal) and never writes an element of the live backing array in place, so the
162+
// saved header still points at the pre-Fire leaves after any commit and a rollback that
163+
// restores it is exact. Aliasing avoids a per-Fire slice allocation on every Fire,
164+
// including the successful ones that never roll back.
165+
//
166+
// The history maps and the raised queue are copied only when NON-EMPTY (copyMap /
167+
// copyLeafMap / the append return nil for an empty input, allocating nothing). recordHistory
168+
// writes the live history maps in place, so a non-empty map must be copied to keep the
169+
// snapshot intact; the steady-state machine carries empty history and an empty queue, so
170+
// this costs no allocation on the hot path. The context and current leaf are values.
171+
// beginTxn never touches a clock or IO, so Fire stays pure.
149172
func (i *Instance[S, E, C]) beginTxn() fireTxn[S, E, C] {
150173
return fireTxn[S, E, C]{
151174
current: i.current,
152-
config: append([]S(nil), i.config...),
175+
config: i.config,
153176
entity: i.entity,
154177
historyShallow: copyMap(i.historyShallow),
155178
historyDeep: copyLeafMap(i.historyDeep),
@@ -158,9 +181,13 @@ func (i *Instance[S, E, C]) beginTxn() fireTxn[S, E, C] {
158181
}
159182

160183
// rollback restores the instance to the state captured by beginTxn, discarding every
161-
// configuration, context, and history mutation a failed macrostep applied. Effects
162-
// are already suppressed on the failure path (the NTE contract), so after rollback
163-
// the instance is byte-for-byte identical to its pre-Fire state.
184+
// configuration, context, and history mutation a failed macrostep applied. It restores
185+
// only the instance's PERSISTED state; the transient FireResult.Effects accumulated
186+
// before the failure are cleared by fireCore right after this call (the single-spine
187+
// commit path returns no effects on failure, but the parallel and run-to-completion
188+
// paths can carry earlier sub-steps' effects, so that clear is what makes the
189+
// no-effects-on-failure contract global). After rollback the instance is byte-for-byte
190+
// identical to its pre-Fire state.
164191
func (i *Instance[S, E, C]) rollback(txn fireTxn[S, E, C]) {
165192
i.current = txn.current
166193
i.config = txn.config

state/fire_transactional_test.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,14 @@ func TestFire_FailedEntryAction_RollsBackConfiguration(t *testing.T) {
8282
t.Fatalf("context balance = %d after a failed Fire; want 1 (unchanged)", got)
8383
}
8484

85+
// (c2) No effects on a failed Fire: the doc contract is absolute — a failed Fire
86+
// returns no effects so a host replaying it cannot double-apply the ones that ran
87+
// before the error. Snapshot() omits the transient FireResult.Effects, so this is
88+
// the only assertion that catches an effect leak.
89+
if len(res.Effects) != 0 {
90+
t.Fatalf("Effects = %v on a failed Fire; want none (no partial effects emitted)", res.Effects)
91+
}
92+
8593
// (d) Snapshot the post-failure instance: it must equal a snapshot of the
8694
// never-Fired control, so nothing split was persisted.
8795
gotSnap := inst.Snapshot()
@@ -125,14 +133,19 @@ func TestFire_FailedRegionEntry_RollsBackEarlierRegion(t *testing.T) {
125133
boom := errors.New("region entry boom")
126134
bump := func(in state.AssignCtx[txnCtx]) txnCtx { c := in.Entity; c.Balance += 10; return c }
127135
fail := func(state.ActionCtx[txnCtx]) (state.Effect, error) { return nil, boom }
136+
// emit produces a real effect on r1's transition so the earlier region contributes
137+
// something to fireParallel's accumulated effects BEFORE r2's entry fails. Without
138+
// it r1 emits no effect and the effects-empty assertion could never observe a leak.
139+
emit := func(state.ActionCtx[txnCtx]) (state.Effect, error) { return "r1-fired", nil }
128140

129141
m := state.Forge[string, string, txnCtx]("txn-par").
130142
Action("explode", fail).
143+
Action("emit", emit).
131144
Reducer("bump", bump).
132145
SuperState("live").
133146
Region("r1").
134147
Initial("r1a").
135-
SubState("r1a").On("go").GoTo("r1b").Assign("bump").
148+
SubState("r1a").On("go").GoTo("r1b").Do("emit").Assign("bump").
136149
SubState("r1b").
137150
EndRegion().
138151
Region("r2").
@@ -167,6 +180,15 @@ func TestFire_FailedRegionEntry_RollsBackEarlierRegion(t *testing.T) {
167180
t.Fatalf("context balance = %d after a failed parallel Fire; want 1 (r1 fold rolled back)", got)
168181
}
169182

183+
// No effects on a failed parallel Fire: r1's earlier, already-committed region
184+
// transition produced real effects that fireParallel accumulated before r2's entry
185+
// failed. The contract is that a failed Fire emits NO effects, so a host cannot
186+
// double-apply r1's effects on replay. Snapshot() omits transient FireResult.Effects,
187+
// so this assertion is the only thing that catches the parallel/RTC leak.
188+
if len(res.Effects) != 0 {
189+
t.Fatalf("Effects = %v on a failed parallel Fire; want none (earlier region's effects must not leak)", res.Effects)
190+
}
191+
170192
gotSnap := inst.Snapshot()
171193
if !reflect.DeepEqual(gotSnap, wantSnap) {
172194
t.Fatalf("post-failure parallel snapshot != never-Fired snapshot:\n got = %+v\nwant = %+v", gotSnap, wantSnap)

0 commit comments

Comments
 (0)