@@ -29,6 +29,7 @@ import {
2929 Scope ,
3030 SubscriptionRef ,
3131} from "effect" ;
32+ import { dual } from "effect/Function" ;
3233
3334import type { Machine , MachineRef } from "../machine.js" ;
3435import type { ActorRef , ActorSystemService , TransitionInfo } from "../actor.js" ;
@@ -81,8 +82,25 @@ export interface RuntimeCellResources<S, E> {
8182 readonly latestTransitionRef : SubscriptionRef . SubscriptionRef < TransitionInfo < S , E > | undefined > ;
8283 readonly eventQueue : Queue . Queue < RuntimeQueuedEvent < S , E > > ;
8384 readonly stoppedRef : Ref . Ref < boolean > ;
85+ readonly listeners : Set < ( state : S ) => void > ;
8486}
8587
88+ /** Notify synchronous state listeners without letting a listener defect fail the runtime. */
89+ export const notifyStateListeners : {
90+ < S > ( state : S ) : ( listeners : Set < ( state : S ) => void > ) => void ;
91+ < S > ( listeners : Set < ( state : S ) => void > , state : S ) : void ;
92+ } = dual ( 2 , < S > ( listeners : Set < ( state : S ) => void > , state : S ) : void => {
93+ for ( const listener of listeners ) {
94+ // Host callbacks can throw. Each listener must stay isolated from the actor runtime.
95+ // eslint-disable-next-line effect/noTryCatch
96+ try {
97+ listener ( state ) ;
98+ } catch {
99+ // Listener defects must not fail the runtime.
100+ }
101+ }
102+ } ) ;
103+
86104// ============================================================================
87105// Runtime interface
88106// ============================================================================
@@ -224,9 +242,17 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function
224242 const services = yield * Effect . context < R > ( ) ;
225243 const fork = Effect . runForkWith ( services ) ;
226244
227- const { stateRef, latestTransitionRef, stoppedRef, eventQueue } = config . cellResources ;
245+ const { stateRef, latestTransitionRef, stoppedRef, eventQueue, listeners } = config . cellResources ;
228246 const pendingRequests = new Set < ( error : ActorStoppedError ) => Effect . Effect < void > > ( ) ;
229247
248+ const loopFiberRef : { current : Fiber . Fiber < void > | undefined } = { current : undefined } ;
249+ const sendSync = ( event : E ) : void => {
250+ if ( Ref . getUnsafe ( stoppedRef ) ) return ;
251+ Queue . offerUnsafe ( eventQueue , { _tag : "send" , event } ) ;
252+ eventQueue . dispatcher . flush ( ) ;
253+ loopFiberRef . current ?. currentDispatcher ?. flush ( ) ;
254+ } ;
255+
230256 // Exit deferred — set exactly once with the exit reason
231257 const exitDeferred = yield * Deferred . make < RuntimeExit < S > > ( ) ;
232258
@@ -265,6 +291,14 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function
265291 state : stateRef ,
266292 latestTransition : latestTransitionRef ,
267293 send : selfSend ,
294+ client : {
295+ send : sendSync ,
296+ getSnapshot : ( ) => SubscriptionRef . getUnsafe ( stateRef ) ,
297+ subscribe : ( listener ) => {
298+ listeners . add ( listener ) ;
299+ return ( ) => listeners . delete ( listener ) ;
300+ } ,
301+ } ,
268302 spawn,
269303 reply : ( value : unknown ) =>
270304 Effect . sync ( ( ) => {
@@ -285,9 +319,6 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function
285319
286320 // Shared mutable refs used by both start() and stop()
287321 const initEvent = { _tag : INTERNAL_INIT_EVENT } as E ;
288- // Mutable holder for the loop fiber — needed by stop() and spawn defect signals
289- const loopFiberRef : { current : Fiber . Fiber < void > | undefined } = { current : undefined } ;
290-
291322 /** Set the exit deferred exactly once. */
292323 const setExit = ( exit : RuntimeExit < S > ) =>
293324 Deferred . succeed ( exitDeferred , exit ) . pipe ( Effect . asVoid ) ;
@@ -351,6 +382,7 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function
351382 event : latest . event ,
352383 } ) ;
353384 }
385+ notifyStateListeners ( listeners , initialResult . newState ) ;
354386 if ( lifecycle ?. onStateChange !== undefined ) {
355387 const stateChange = lifecycle . onStateChange ( initialResult , initEvent ) ;
356388 if ( isEffect ( stateChange ) ) yield * stateChange ;
@@ -375,7 +407,10 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function
375407 self,
376408 system,
377409 } )
378- . pipe ( Effect . forkIn ( actorScope , { startImmediately : true } ) ) ;
410+ . pipe (
411+ Effect . provideService ( Scope . Scope , actorScope ) ,
412+ Effect . forkIn ( actorScope , { startImmediately : true } ) ,
413+ ) ;
379414 backgroundFibers . push ( fiber ) ;
380415 }
381416
@@ -443,6 +478,7 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function
443478 eventQueue ,
444479 pendingRequests ,
445480 stoppedRef ,
481+ listeners ,
446482 self ,
447483 stateScopeRef ,
448484 actorId ,
@@ -537,7 +573,7 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function
537573 eventQueue ,
538574 pendingRequests ,
539575 exitDeferred ,
540- ( ) => loopFiberRef . current ?. currentDispatcher ?. flush ( ) ,
576+ sendSync ,
541577 ) ,
542578 stop : stop . pipe ( Effect . provide ( services ) ) ,
543579 start : start . pipe ( Effect . provide ( services ) ) ,
@@ -556,7 +592,7 @@ const makeHandle = <S extends { readonly _tag: string }, E extends { readonly _t
556592 eventQueue : Queue . Queue < RuntimeQueuedEvent < S , E > > ,
557593 pendingRequests : Set < ( error : ActorStoppedError ) => Effect . Effect < void > > ,
558594 exitDeferred : Deferred . Deferred < RuntimeExit < S > > ,
559- flushLoop : ( ) => void ,
595+ sendSync : ( event : E ) => void ,
560596) : RuntimeHandle < S , E > => {
561597 const track = < A , RequestError > (
562598 deferred : Deferred . Deferred < A , RequestError > ,
@@ -612,13 +648,7 @@ const makeHandle = <S extends { readonly _tag: string }, E extends { readonly _t
612648 yield * Queue . offer ( eventQueue , { _tag : "drain" , done } ) ;
613649 yield * Deferred . await ( done ) ;
614650 } ) . pipe ( Effect . asVoid ) ,
615- sendSync : ( event : E ) => {
616- const stopped = Effect . runSync ( Ref . get ( stoppedRef ) ) ;
617- if ( stopped ) return ;
618- Effect . runSync ( Queue . offer ( eventQueue , { _tag : "send" , event } ) ) ;
619- eventQueue . dispatcher . flush ( ) ;
620- flushLoop ( ) ;
621- } ,
651+ sendSync,
622652 getState : SubscriptionRef . get ( stateRef ) ,
623653 stateRef,
624654 latestTransitionRef,
@@ -655,6 +685,7 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
655685 eventQueue : Queue . Queue < RuntimeQueuedEvent < S , E > > ,
656686 pendingRequests : Set < ( error : ActorStoppedError ) => Effect . Effect < void > > ,
657687 stoppedRef : Ref . Ref < boolean > ,
688+ listeners : Set < ( state : S ) => void > ,
658689 self : MachineRef < E , S > ,
659690 stateScopeRef : { current : Scope . Closeable } ,
660691 actorId : string ,
@@ -761,6 +792,7 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
761792 generation ,
762793 ) ;
763794 }
795+ notifyStateListeners ( listeners , result . newState ) ;
764796 }
765797
766798 // Lifecycle: onStateChange (actor notifies listeners and saves durability)
0 commit comments