Skip to content

Commit 82b5d46

Browse files
authored
Merge pull request #65 from cevr/fix/machine-ref-client
feat(machine): expose synchronous self client
2 parents d214119 + 57c91bd commit 82b5d46

8 files changed

Lines changed: 128 additions & 33 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"effect-machine": minor
3+
---
4+
5+
Expose a narrow synchronous client to machine-owned Effects. Bind each background resource to its actor generation.

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,9 @@ Effect Machine does not add an action queue or a second context system.
9696
Effect requirements remain in `R`. A machine cannot start until the application provides every required service. Effectful transition handlers must have `never` in their error channel. Convert expected failures to states or events.
9797

9898
Machine-lifetime backgrounds can read `self.state` and `self.latestTransition`. These are the
99-
actor-owned subscription refs. They stay stable across supervision generations. Treat them as
100-
read-only and use `SubscriptionRef.get` or `SubscriptionRef.changes` to observe them.
99+
actor-owned subscription refs. They stay stable across supervision generations. Use
100+
`SubscriptionRef.get` or `SubscriptionRef.changes` for Effect workflows. Use `self.client` for
101+
synchronous host callbacks that need `send`, `getSnapshot`, or `subscribe`.
101102

102103
Read [the Effect model](./docs/effect-model.md) and [async work ownership](./docs/async-work.md).
103104

‎SKILL.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ const machine = Machine.make({
5252
| `.background(handler)` | Machine-lifetime effect |
5353
| `.final(state)` | Mark final state |
5454

55+
Use `self.client` in a host callback that must send or observe state synchronously. Use
56+
`self.state` and `self.latestTransition` in Effect workflows. A background resource receives the
57+
actor generation Scope. The runtime closes that Scope when the actor generation stops.
58+
5559
## State.with()
5660

5761
Construct state from existing source:

‎src/actor.ts‎

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import type { NoReplyError } from "./errors.js";
4343
import { DuplicateActorError, ActorStoppedError } from "./errors.js";
4444
import {
4545
createRuntime,
46+
notifyStateListeners,
4647
type RuntimeLifecycleHooks,
4748
type RuntimeQueuedEvent,
4849
type RuntimeHandle,
@@ -408,19 +409,6 @@ export class ActorScope extends Context.Service<ActorScope, Scope.Scope>()(
408409
/** Listener set for sync subscriptions */
409410
type Listeners<S> = Set<(state: S) => void>;
410411

411-
/**
412-
* Notify all listeners of state change.
413-
*/
414-
const notifyListeners = <S>(listeners: Listeners<S>, state: S): void => {
415-
for (const listener of listeners) {
416-
try {
417-
listener(state);
418-
} catch {
419-
// Ignore listener failures to avoid crashing the actor loop
420-
}
421-
}
422-
};
423-
424412
const toActorExit = <S, O>(
425413
machine: { readonly _output: (state: S) => O },
426414
exit: RuntimeExit<S>,
@@ -788,7 +776,7 @@ const runSupervisionLoop = <
788776
yield* options.onRestart(nextGeneration, generationExit);
789777
}
790778

791-
notifyListeners(cell.listeners, restartState);
779+
notifyStateListeners(cell.listeners, restartState);
792780
}
793781
});
794782

@@ -913,7 +901,6 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
913901
onEvent,
914902
onStateChange: (result, event) =>
915903
Effect.gen(function* () {
916-
notifyListeners(listeners, result.newState);
917904
const durability = lifecycle?.durability;
918905
if (durability === undefined || !result.transitioned) return;
919906
const shouldPersist =
@@ -974,6 +961,7 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
974961
latestTransitionRef,
975962
stoppedRef,
976963
eventQueue: currentQueue,
964+
listeners,
977965
},
978966
lifecycle: buildRuntimeLifecycle(runtimeGeneration),
979967
onChildSpawned: (childId, child) =>

‎src/cluster/entity-machine.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ export const EntityMachine = {
188188
);
189189
const stoppedRef = yield* Ref.make(false);
190190
const eventQueue = yield* Queue.unbounded<RuntimeQueuedEvent<S, E>>();
191+
const listeners = new Set<(state: S) => void>();
191192
let hooks: ReturnType<typeof makeInspectionHooks<S, E>> | undefined = undefined;
192193
if (inspector !== undefined) {
193194
hooks = makeInspectionHooks(entityId, inspector);
@@ -199,7 +200,7 @@ export const EntityMachine = {
199200
actorId: entityId,
200201
hooks,
201202
childIdPrefix: `${entityId}/`,
202-
cellResources: { stateRef, latestTransitionRef, stoppedRef, eventQueue },
203+
cellResources: { stateRef, latestTransitionRef, stoppedRef, eventQueue, listeners },
203204
}),
204205
);
205206
yield* withActorInspection(runtime.start);

‎src/internal/runtime.ts‎

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
Scope,
3030
SubscriptionRef,
3131
} from "effect";
32+
import { dual } from "effect/Function";
3233

3334
import type { Machine, MachineRef } from "../machine.js";
3435
import 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)

‎src/machine.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import {
5656
type MachineInitialization as MachineInitializationType,
5757
} from "./internal/machine-initialization.js";
5858
import type { BackgroundEffect, SpawnEffect, Transition } from "./internal/machine-definition.js";
59-
import type { ActorRef, ActorSystemService, TransitionInfo } from "./actor.js";
59+
import type { ActorClient, ActorRef, ActorSystemService, TransitionInfo } from "./actor.js";
6060
import type { InspectorService } from "./inspection.js";
6161

6262
// ============================================================================
@@ -74,6 +74,11 @@ export interface MachineRef<Event, State = never> {
7474
TransitionInfo<State, Event> | undefined
7575
>;
7676
readonly send: (event: Event) => Effect.Effect<void>;
77+
/** Synchronous client for host callback boundaries. */
78+
readonly client: Pick<
79+
ActorClient<State & { readonly _tag: string }, Event>,
80+
"send" | "getSnapshot" | "subscribe"
81+
>;
7782
readonly spawn: <S2 extends { readonly _tag: string }, E2 extends { readonly _tag: string }, R2>(
7883
id: string,
7984
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1091,6 +1096,7 @@ export class Machine<
10911096

10921097
/**
10931098
* Machine-lifetime effect that is forked on actor spawn and runs until the actor stops.
1099+
* Each handler receives the generation Scope. It closes when the generation stops.
10941100
*
10951101
* @example
10961102
* ```ts

‎test/machine-self-observation.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,64 @@ interface SelfSource {
3131
}
3232

3333
describe("machine self observation", () => {
34+
it.scopedLive("provides an inline host client to machine-owned effects", () =>
35+
Effect.gen(function* () {
36+
const observedStates: Array<TestState["_tag"]> = [];
37+
let defectiveListenerCalls = 0;
38+
const released = yield* Deferred.make<void>();
39+
const machine = Machine.make({
40+
state: TestState,
41+
event: TestEvent,
42+
initial: TestState.Idle,
43+
})
44+
.on(TestState.Idle, TestEvent.Start, ({ event }) =>
45+
TestState.Active({ count: event.count }),
46+
)
47+
.on(TestState.Active, TestEvent.Increment, ({ state }) =>
48+
TestState.Active({ count: state.count + 1 }),
49+
)
50+
.background(({ self }) =>
51+
Effect.acquireRelease(
52+
Effect.sync(() =>
53+
self.client.subscribe((state) => {
54+
const exactState: TestState = state;
55+
observedStates.push(exactState._tag);
56+
if (TestState.$is("Active")(exactState) && exactState.count === 1) {
57+
self.client.send(TestEvent.Increment);
58+
}
59+
}),
60+
),
61+
(unsubscribe) =>
62+
Effect.sync(unsubscribe).pipe(Effect.andThen(Deferred.succeed(released, undefined))),
63+
).pipe(Effect.andThen(Effect.never)),
64+
)
65+
.background(({ self }) =>
66+
Effect.acquireRelease(
67+
Effect.sync(() =>
68+
self.client.subscribe(() => {
69+
defectiveListenerCalls += 1;
70+
// This host callback must throw to test defect isolation.
71+
// eslint-disable-next-line effect/noThrowStatement, effect/noNewError
72+
throw new Error("listener defect");
73+
}),
74+
),
75+
(unsubscribe) => Effect.sync(unsubscribe),
76+
).pipe(Effect.andThen(Effect.never)),
77+
);
78+
79+
const actor = yield* Machine.spawn(machine);
80+
yield* actor.start;
81+
82+
actor.client.send(TestEvent.Start({ count: 1 }));
83+
84+
expect(actor.client.getSnapshot()).toEqual(TestState.Active({ count: 2 }));
85+
expect(observedStates).toEqual(["Active", "Active"]);
86+
expect(defectiveListenerCalls).toBe(2);
87+
yield* actor.stop;
88+
yield* Deferred.await(released);
89+
}),
90+
);
91+
3492
it.scopedLive("shares typed actor refs for accepted transitions and stop cleanup", () =>
3593
Effect.gen(function* () {
3694
const sourceReady = yield* Deferred.make<SelfSource>();

0 commit comments

Comments
 (0)