19. The checkout trace, step by step
This chapter follows one program through the machine before Chapter 20 defines the machine. The program is the checkout from Chapter 9, simplified, with a request-scoped audit log added so the trace shows which Context each piece of code sees. Kernel test T1 runs exactly this program, lowered by hand.
service AuditLog { info(message: string): void!! }
service Inventory { reserve(cartId: string): Hold!!Scope }
service Reviewers { decide(request: string): boolean!! }
control Approval {
approve(request: string): boolean!!
}
function checkout(cartId: string) {
using hold = Inventory.reserve(cartId) // released when checkout's body exits
const ok = requestApproval(cartId)
if (!ok) throw new Declined({ cartId })
return `charged ${cartId} holding ${hold}`
}
function requestApproval(cartId: string) {
AuditLog.info(`approval needed for ${cartId}`)
const ok = Approval.approve(`checkout ${cartId}`) // the capture happens here
AuditLog.info(`approval ${ok ? "granted" : "denied"}`)
return ok
}
const receipt = handle(
() => Effect.provideService(checkout("c-42"), AuditLog, RequestAuditLog),
Approval,
{
approve(request, k) {
AuditLog.info(`clause handling "${request}"`) // runs outside the provide: AppAuditLog
const decision = Reviewers.decide(request) // the whole fiber waits on a timer
AuditLog.info(`clause resuming with ${decision}`)
return resume(k, decision)
}
}
)The generated TypeScript for the handle (the runtime package's API is shown as the compiler will emit it; the kernel prototype also takes a label):
const receipt = yield* handle(Approval,
Effect.provideService(Effect.suspend(() => checkout("c-42")), AuditLog, RequestAuditLog),
{
approve: Effect.fnUntraced(function* (request: string, k: Continuation<boolean, never, string>) {
const auditLog = yield* AuditLog
yield* auditLog.info(`clause handling "${request}"`)
const reviewers = yield* Reviewers
const decision = yield* reviewers.decide(request)
yield* auditLog.info(`clause resuming with ${decision}`)
return tailResume(k, decision) // a tail resume: returned, never yielded
})
})The machine, step by step
Notation: K is the fiber's frame stack, listed bottom to top; Γ is its Context. Prompt marks where handle was installed, Restore undoes a provideService, Final(S₁) closes checkout's Scope, Owner(k) holds the captured continuation while the clause runs, and Clause(k) performs a tail resume the clause returns. checkout's using makes its body a Scope region, so checkout has two frames: the function frame and the region frame checkout/scope (Chapter 26).
① just before the perform (fiber 1)
K = … main │ Prompt(Approval, Γ₀) │ Restore(Γ₀′) │ checkout │ Final(S₁) │ checkout/scope │ requestApproval
Γ = { AuditLog: RequestAuditLog, Approval → prompt, Scope: S₁ open }
② Approval.approve(…) captures 5 frames
k.K₁ = [ Restore(Γ₀′) │ checkout │ Final(S₁) │ checkout/scope │ requestApproval ]
k.Γ = { AuditLog: RequestAuditLog, Approval → prompt, Scope: S₁ open }
K = … main │ Owner(k) │ Clause(k) │ clause
Γ = Γ₀ = { AuditLog: AppAuditLog, … } the clause runs at the prompt
③ Reviewers.decide → Effect.sleep → fiber 1 parks at its root
K is intact: … main │ Owner(k) │ Clause(k) │ clause │ (sleep's frames)
another fiber runs; S₁ is still open; the hold is still held
④ timer fires → fiber 1 wakes at the root → the clause returns tailResume(k, true)
→ Clause(k) resumes k; Owner(k) is inert and is dropped
K = … main │ Prompt(Approval, Γ₀) │ Restore(Γ₀′) │ checkout │ Final(S₁) │ checkout/scope │ requestApproval
Γ = k.Γ RequestAuditLog and S₁ are back
⑤ requestApproval returns true → checkout returns → Final(S₁) releases the hold
→ Restore → Prompt restores Γ₀ → handle returns the receiptThe kernel's own trace from bun test tests/trace.test.ts (frame labels: OnExit is Effect's restore or finalizer frame, fn x is a compiled function frame, Iterator is an Effect.gen frame). T1 names the service Logger and its two implementations AppLogger and RequestLogger. T1 writes its clause by hand as a generator that ends in return yield* resume(k, decision), which is not a tail resume, so its reinstated stack keeps the clause's frames (Owner, Clause, and the clause's Iterator) under the new prompt. Step ④ above shows the compiled shape, which drops them (K11a):
capture Approve on fiber 1: 5 frames
segment (bottom->top): OnExit | fn checkout | OnExit | fn checkout/scope | fn requestApproval
saved context: Logger=RequestLogger, Approve->Prompt, Scope(Open)
resume Approve on fiber 1
reinstated top: OnExit | OnExit | OnExit | Iterator | Owner | Clause | Iterator | Prompt | OnExit | fn checkout | OnExit | fn checkout/scope | fn requestApproval
restored context: Logger=RequestLogger, Approve->Prompt, Scope(Open)
events:
reserve c-42
[RequestLogger] approval needed for c-42
[AppLogger] clause handling "checkout c-42"
other fiber ran while the main fiber was suspended
reviewer approved "checkout c-42"
[AppLogger] clause resuming with true
[RequestLogger] approval granted
release hold-1What the trace shows
- Two continuations exist at step ③, of different kinds.
kis a handler continuation: five frames owned by the clause. The fiber's own stack, parked byEffect.sleep, is a root continuation owned by the scheduler. Both are one-shot; the first is the kernel's, the second is Effect's. - The handler runs outside its own
provide. The clause logs throughAppAuditLog; the resumed body logs throughRequestAuditLogagain (Chapter 22). - Resources stay open while a continuation waits. The hold is released once, at step ⑤, when
checkoutreturns. - Ownership is explicit.
Owner(k)stays under the clause until the clause finishes. A clause that ends with a tail resume finishes first, so its owner is dropped askresumes; a clause that does work afterresumekeeps it until that work ends (Chapter 20).