Skip to content

Commit 7c8d86c

Browse files
Compact process supervision (#42)
1 parent 4d6e7b0 commit 7c8d86c

3 files changed

Lines changed: 115 additions & 100 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@typeonce/effect-machine": patch
3+
---
4+
5+
Reduce the memory retained by running machines by consolidating process termination into a single supervisor signal.

src/internal/machineRuntime.ts

Lines changed: 80 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as Context from "effect/Context"
1010
import * as Deferred from "effect/Deferred"
1111
import * as Effect from "effect/Effect"
1212
import * as Exit from "effect/Exit"
13+
import * as Fiber from "effect/Fiber"
1314
import * as HashMap from "effect/HashMap"
1415
import * as Option from "effect/Option"
1516
import * as PubSub from "effect/PubSub"
@@ -304,13 +305,17 @@ const startInternal: <
304305
logic: ProcessLogic<State, Event, Error, Requirements, Output, InitialError>,
305306
options: StartInternalOptions
306307
) {
308+
type ProcessTermination =
309+
| { readonly _tag: "Stopped" }
310+
| { readonly _tag: "Done"; readonly output: Output }
311+
| { readonly _tag: "Failure"; readonly cause: Cause.Cause<Error> }
312+
307313
const sessionId = yield* options.runtime.nextSessionId
308314
const id = options.id ?? sessionId
309315
const queue = yield* Queue.unbounded<Event>()
310-
const stopRequested = yield* Deferred.make<void>()
311-
const terminalized = yield* Deferred.make<void>()
312-
const externalFailure = yield* Deferred.make<never, Error>()
316+
const termination = yield* Deferred.make<ProcessTermination>()
313317
const done = yield* Deferred.make<Output, Error | StoppedError>()
318+
const awaitCompletion = Deferred.await(done).pipe(Effect.exit, Effect.asVoid)
314319
const childResourcesState = yield* SynchronizedRef.make<ChildResourcesState>({
315320
closed: false,
316321
resources: undefined
@@ -345,9 +350,7 @@ const startInternal: <
345350

346351
const cleanupStartupFailure = <A, E>(exit: Exit.Exit<A, E>): Effect.Effect<void> =>
347352
Exit.isFailure(exit)
348-
? closeChildren(exit).pipe(
349-
Effect.ensuring(Deferred.succeed(terminalized, void 0))
350-
)
353+
? closeChildren(exit)
351354
: Effect.void
352355

353356
const cleanup = options.onStop ?? Effect.void
@@ -594,7 +597,7 @@ const startInternal: <
594597
}
595598

596599
let initializing = true
597-
const requestStop = Deferred.succeed(stopRequested, void 0).pipe(Effect.asVoid)
600+
const requestStop = Deferred.succeed(termination, { _tag: "Stopped" }).pipe(Effect.asVoid)
598601
const self: ProcessAddress<Event> = {
599602
id,
600603
sessionId,
@@ -621,7 +624,11 @@ const startInternal: <
621624
sendParent,
622625
sendTo,
623626
stopChild,
624-
failCause: (cause) => Deferred.failCause(externalFailure, cause as Cause.Cause<Error>)
627+
failCause: (cause) =>
628+
Deferred.succeed(termination, {
629+
_tag: "Failure",
630+
cause: cause as Cause.Cause<Error>
631+
}).pipe(Effect.asVoid)
625632
}
626633

627634
const initial = yield* logic.initial(scope).pipe(
@@ -769,8 +776,7 @@ const startInternal: <
769776
Effect.andThen(closeChildren(exit)),
770777
Effect.andThen(setAndPublishSnapshot(snapshot)),
771778
Effect.andThen(cleanup),
772-
Effect.andThen(completeDone),
773-
Effect.ensuring(Deferred.succeed(terminalized, void 0))
779+
Effect.andThen(completeDone)
774780
)
775781
)
776782

@@ -820,18 +826,8 @@ const startInternal: <
820826
return terminalizeWith(snapshot, exit, Deferred.succeed(done, output))
821827
}
822828

823-
const terminalizeStop: Effect.Effect<void> = Effect.uninterruptible(
824-
reserveStoppedSnapshot.pipe(
825-
Effect.flatMap((snapshot) =>
826-
snapshot === undefined
827-
? Deferred.await(terminalized)
828-
: terminalizeReservedStop(snapshot)
829-
)
830-
)
831-
)
832-
833829
const stop: Effect.Effect<void> = Effect.uninterruptible(
834-
requestStop.pipe(Effect.andThen(Deferred.await(terminalized)))
830+
requestStop.pipe(Effect.andThen(awaitCompletion))
835831
)
836832

837833
const context: ProcessContext<State, Event> = {
@@ -907,93 +903,77 @@ const startInternal: <
907903
yield* options.onReady(ref)
908904
}
909905

910-
type ProcessTermination =
911-
| { readonly _tag: "Stopped"; readonly snapshot: RuntimeSnapshot<State, Error, Output> }
912-
| {
913-
readonly _tag: "Done"
914-
readonly snapshot: RuntimeSnapshot<State, Error, Output>
915-
readonly output: Output
906+
const reserveTermination = (termination: ProcessTermination) => {
907+
switch (termination._tag) {
908+
case "Stopped":
909+
return reserveStoppedSnapshot
910+
case "Done":
911+
return reserveSuccessSnapshot(termination.output)
912+
case "Failure":
913+
return reserveFailureSnapshot(termination.cause)
916914
}
917-
| {
918-
readonly _tag: "Failure"
919-
readonly snapshot: RuntimeSnapshot<State, Error, Output>
920-
readonly cause: Cause.Cause<Error>
915+
}
916+
917+
const completeTermination = (
918+
termination: ProcessTermination,
919+
snapshot: RuntimeSnapshot<State, Error, Output>
920+
) => {
921+
switch (termination._tag) {
922+
case "Stopped":
923+
return terminalizeReservedStop(snapshot)
924+
case "Done":
925+
return terminalizeReservedSuccess(snapshot, termination.output)
926+
case "Failure":
927+
return terminalizeReservedFailure(snapshot, termination.cause)
921928
}
929+
}
922930

923-
const arbitration = yield* Deferred.make<ProcessTermination>()
924-
// Only the actual worker and stop waiter are restored to interruptibility.
925-
// Reserving a terminal snapshot, publishing the shared arbitration result,
926-
// and terminalizing stay masked so scope interruption cannot abandon a
927-
// reservation. Both contenders read the same result: a contender that loses
928-
// the reservation cannot finish the race with a different outcome.
929-
const runFiber: Effect.Effect<void, never, Requirements> = Effect.uninterruptibleMask((restore) =>
930-
Deferred.poll(stopRequested).pipe(
931-
Effect.flatMap((requested) => {
932-
if (Option.isSome(requested)) {
933-
return terminalizeStop
934-
}
935-
const awaitArbitration = Deferred.await(arbitration)
936-
const completeArbitration = (termination: ProcessTermination) =>
937-
Deferred.succeed(arbitration, termination).pipe(
938-
Effect.andThen(awaitArbitration)
939-
)
940-
const stopContender = restore(Deferred.await(stopRequested)).pipe(
941-
Effect.andThen(reserveStoppedSnapshot),
942-
Effect.flatMap((snapshot) =>
943-
snapshot === undefined
944-
? awaitArbitration
945-
: completeArbitration({ _tag: "Stopped", snapshot })
946-
)
947-
)
948-
const workerContender: Effect.Effect<ProcessTermination, never, Requirements> = restore(
949-
Effect.raceFirst(
950-
Effect.suspend(() => logic.run(context)),
951-
Deferred.await(externalFailure)
952-
)
953-
).pipe(
954-
Effect.exit,
955-
Effect.flatMap((exit) =>
931+
const forkRuntime = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
932+
options.fiberScope !== undefined
933+
? Effect.forkIn(effect, options.fiberScope)
934+
: options.detached === true
935+
? Effect.forkDetach(effect)
936+
: Effect.forkChild(effect)
937+
938+
const pendingTermination = yield* Deferred.poll(termination)
939+
const worker = Option.isNone(pendingTermination)
940+
? yield* Effect.uninterruptibleMask((restore) =>
941+
restore(Effect.suspend(() => logic.run(context))).pipe(
942+
Effect.exit,
943+
Effect.flatMap((exit) =>
944+
Deferred.succeed(
945+
termination,
956946
Exit.isFailure(exit)
957-
? reserveFailureSnapshot(exit.cause).pipe(
958-
Effect.flatMap((snapshot) =>
959-
snapshot === undefined
960-
? awaitArbitration
961-
: completeArbitration({ _tag: "Failure", snapshot, cause: exit.cause })
962-
)
963-
)
964-
: reserveSuccessSnapshot(exit.value).pipe(
965-
Effect.flatMap((snapshot) =>
966-
snapshot === undefined
967-
? awaitArbitration
968-
: completeArbitration({ _tag: "Done", snapshot, output: exit.value })
969-
)
970-
)
947+
? { _tag: "Failure", cause: exit.cause }
948+
: { _tag: "Done", output: exit.value }
971949
)
972950
)
973-
return Effect.raceFirst(workerContender, stopContender).pipe(
974-
Effect.flatMap((termination) => {
975-
switch (termination._tag) {
976-
case "Stopped":
977-
return terminalizeReservedStop(termination.snapshot)
978-
case "Done":
979-
return terminalizeReservedSuccess(termination.snapshot, termination.output)
980-
case "Failure":
981-
return terminalizeReservedFailure(termination.snapshot, termination.cause)
982-
}
983-
})
984-
)
985-
})
986-
)
987-
)
951+
)
952+
).pipe(forkRuntime)
953+
: undefined
954+
955+
// One Deferred arbitrates all terminal causes. The supervisor reserves the
956+
// terminal snapshot before interrupting the worker, so worker finalizers
957+
// cannot mutate the frozen state. It then waits for those finalizers before
958+
// publishing and completing `join` / `stop`.
959+
const runFiber: Effect.Effect<void, never, Requirements> = Effect.uninterruptibleMask((restore) =>
960+
Effect.gen(function*() {
961+
const requested = Option.isSome(pendingTermination)
962+
? yield* pendingTermination.value
963+
: yield* restore(Deferred.await(termination))
988964

989-
yield* runFiber.pipe(
990-
(effect) =>
991-
options.fiberScope !== undefined ?
992-
Effect.forkIn(effect, options.fiberScope)
993-
: options.detached === true ?
994-
Effect.forkDetach(effect)
995-
: Effect.forkChild(effect)
965+
const snapshot = yield* reserveTermination(requested)
966+
if (worker !== undefined) {
967+
yield* Fiber.interrupt(worker)
968+
}
969+
if (snapshot === undefined) {
970+
return yield* awaitCompletion
971+
}
972+
return yield* completeTermination(requested, snapshot)
973+
})
996974
)
975+
976+
yield* forkRuntime(runFiber)
997977
yield* Effect.yieldNow
998978

999979
return ref

test/MachineProcessLifecycle.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,36 @@ describe("machine process lifecycle", () => {
114114
assert.deepStrictEqual(yield* ref.snapshot, { status: "stopped", state: 1 })
115115
}))
116116

117+
it.effect("interrupts the worker before publishing an externally requested failure", () =>
118+
Effect.gen(function*() {
119+
const runtime = yield* Deferred.make<MachineRuntime.ProcessScope<never>>()
120+
const cleanupCount = yield* Ref.make(0)
121+
const logic: MachineRuntime.ProcessLogic<number, never, string> = {
122+
initial: (scope) => Deferred.succeed(runtime, scope).pipe(Effect.as(1)),
123+
run: () =>
124+
Effect.never.pipe(
125+
Effect.ensuring(Ref.update(cleanupCount, (count) => count + 1))
126+
)
127+
}
128+
const ref = yield* MachineRuntime.startProcess(
129+
logic
130+
)
131+
132+
yield* (yield* Deferred.await(runtime)).failCause(Cause.fail("external"))
133+
const joined = yield* Effect.exit(ref.join)
134+
135+
assert.strictEqual(yield* Ref.get(cleanupCount), 1)
136+
assert.deepStrictEqual(yield* ref.snapshot, {
137+
status: "error",
138+
state: 1,
139+
cause: Cause.fail("external")
140+
})
141+
assert(Exit.isFailure(joined))
142+
if (Exit.isFailure(joined)) {
143+
assert.strictEqual(joined.cause.reasons.find(Cause.isFailReason)?.error, "external")
144+
}
145+
}))
146+
117147
it.effect("completes a first changes subscription started after terminalization", () =>
118148
Effect.gen(function*() {
119149
const ref = yield* MachineRuntime.startProcess(

0 commit comments

Comments
 (0)