The program asks for state; the handler supplies it.An effect is a request whose meaning is supplied by a handlerCodegetAn operation and the handler that answers itProgram asksgetrequest the stateHandler replies21the current state
bind passes that reply to the rest of the program.A continuation: the function to run after get repliesCodes: put (s * 2)The continuation turns the reply into the next requestget repliess = 21continuationNext requestput (s * 2)Handler storesstate = 42
The program changes the state, then returns the original value.Read state, store twice the value, return the originalCodebind get (s: bind (put (s * 2)) (_: pure s))Follow the requests and repliesget repliess = 21put storesstate = 42pure s returns21the original valueThe state ends at 42; the program returns 21.nix-effects / README.md
How far can these recursive calls nest?Trace: recursive 3. Recorded run: n = 100000.Coderecursive = count: if count == 0 then 0 else 1 + recursive (count - 1);recursive 3 nests four calls before any additionrecursive 3 nests four calls before any additionEach call waits for the next one and owes an addition. Four calls are open when the base case returns zero; the replies then come back one, two, three. A large enough count reaches the stack limit before any call returns.1234stack limitrecursive 00recursive 11recursive 22recursive 33callerexecution orderopen callsrecursive 3 nests four calls before any additionEach call waits for the next one and owes an addition. Four calls are open when the base case returns zero; the replies then come back one, two, three. A large enough count reaches the stack limit before any call returns.1234stack limitrecursive 00recursive 11recursive 22recursive 33callerexecution orderopen callsIllustrative trace; spacing shows order, not elapsed time.recursive n?EvaluateExplore code
The driver calls one step.Every step gives control back to the driverEvery step gives control back to the driverThree calls to step each return to the driver before the next begins, so depth never exceeds one. The first two replies say what to do next; the last reply is the result.1234stack limitstepnextstepnextstep3driverexecution orderopen step callsEvery step gives control back to the driverThree calls to step each return to the driver before the next begins, so depth never exceeds one. The first two replies say what to do next; the last reply is the result.1234stack limitstepnextstepnextstep3driverexecution orderopen step callsThe driver has called the first step. It is the only call above the driver.
That call returns before the next one begins.Every step gives control back to the driverEvery step gives control back to the driverThree calls to step each return to the driver before the next begins, so depth never exceeds one. The first two replies say what to do next; the last reply is the result.1234stack limitstepnextstepnextstep3driverexecution orderopen step callsEvery step gives control back to the driverThree calls to step each return to the driver before the next begins, so depth never exceeds one. The first two replies say what to do next; the last reply is the result.1234stack limitstepnextstepnextstep3driverexecution orderopen step callsThe first step has returned its reply. Its call is gone before the next one starts.
More steps. The same nesting depth.Every step gives control back to the driverEvery step gives control back to the driverThree calls to step each return to the driver before the next begins, so depth never exceeds one. The first two replies say what to do next; the last reply is the result.1234stack limitstepnextstepnextstep3driverexecution orderopen step callsEvery step gives control back to the driverThree calls to step each return to the driver before the next begins, so depth never exceeds one. The first two replies say what to do next; the last reply is the result.1234stack limitstepnextstepnextstep3driverexecution orderopen step callsSame scale as the recursive trace: more steps, but the depth stays at one.
A fold passes the running total to this function.One addition per inputCodetotal: _: total + 1One call, starting from zeroAccumulatortotal = 0the initial valuetotal + 1Next accumulator1becomes total in the next call
Can a builtin fold run this counter?scalarFold, with n = 100000Codebuiltins.foldl' (total: _: total + 1) 0 (builtins.genList (i: i) n)scalarFold?EvaluateExplore code
The operator chooses a successor state or ends the traversal.genericClosure owns the loop; operator chooses the next workStartstartSetthe initial recordsFor each new recordoperator itemreturns a listNo successors[]this branch stopsSuccessors[ successor ]queued if the key is newQueued records are passed to operator in turnThe result lists every visited record. Keys prevent revisiting the same identity.
Start with one record: an identity and a running total.genericClosure input; total stands in for the handler's stateCodestartSet = [{ key = 0; total = 0; }];One state, two jobskey = 0Identity used by the worklisttotal = 0Payload used by our counter
Build the successor from the current record, item.Inside the operator: the next total, then the successor recordCodenext = { total = item.total + 1; }; { key = item.key + 1; inherit (next) total; }The first call to operatorCurrent itemkey = 0total = 0the seedoperatorSuccessorkey = item.key + 1inherit (next) totalno addition has run yet
Return no successors when the key reaches the limit.Operator excerpt; the else branch returns the successor in a listCodeoperator = item: if item.key >= n then [] elseThe operator returns a listConditionoperator returnsEffectitem.key < n[ successor ]one more stateitem.key >= n[]this branch stopsn = 100000. The seed has key 0; the last visited record has key n.
naive names the returned list, including the initial state.Combine the seed and operator we just builtCodenaive = builtins.genericClosure { inherit startSet operator; };The returned list is named naiveFirst elementkey = 0the seedSecond elementkey = 1…Last elementkey = nselected by last naivebuiltins.length naive and (last naive).total are the next two questions.
Did visiting every state compute the answer?naive is the list of states returned by my first attempt at a counter.How many states were visited?builtins.length naive?EvaluateExplore codeWhat is the final total?(last naive).total?EvaluateExplore codeCounting the visited states tells us nothing about whether their totals have been computed.
I had a rather generous definition of finished.The loop has finished.I shall now make the mistake of asking for the answer.
The key and the total have separate demand paths.The two field expressions, extracted from the operatorCodenext.total = item.total + 1; key = item.key + 1;Empty sourceKeys move forward; each total refers backkeytotal00successorrefers to1total[0] + 12total[1] + 1computed numberdeferred expressionevaluated recordThree states shown; total[k] is the total of the state with key k.Visiting evaluates each record and its keykeytotal00successorrefers to1total[0] + 12total[1] + 1computed numberdeferred expressionevaluated recordThree states shown; total[k] is the total of the state with key k.Reading the final total climbs through every deferred additionReading the final total climbs through every deferred additionBuilding the states returns after each step without evaluating any total. Reading the final total forces the previous total, which forces the one before it. Each forced addition waits inside the previous one until the evaluator reaches its stack limit.1234stack limittotal[n−2]total[n−1]total[n]⋯stack overflowevaluatorvisit statesread totalforcing depthReading the final total climbs through every deferred additionBuilding the states returns after each step without evaluating any total. Reading the final total forces the previous total, which forces the one before it. Each forced addition waits inside the previous one until the evaluator reaches its stack limit.1234stack limittotal[n−2]total[n−1]total[n]⋯stack overflowevaluatorvisit statesread totalforcing depthIllustrative trace for n steps; the recorded run overflows at n = 100000.seq next evaluates the record, not its totalkeytotal00successorrefers to1total[0] + 12total[1] + 1computed numberdeferred expressionevaluated recordThree states shown; total[k] is the total of the state with key k.First transition: compute 1, then expose key 1keytotal00successorrefers to11next?computed numberdeferred expressionevaluated recordThree states shown; total[k] is the total of the state with key k.Each transition carries a computed numberkeytotal00successorrefers to1122computed numberdeferred expressionevaluated recordThree states shown; total[k] is the total of the state with key k.Each step computes its total before the next beginsEach step computes its total before the next beginsEvery step forces its own addition, one level deep, because the previous total is already a number. Reading the final total then needs no chain of earlier additions.1234stack limittotal[n]100000⋯evaluatorvisit statesread totalforcing depthEach step computes its total before the next beginsEvery step forces its own addition, one level deep, because the previous total is already a number. Reading the final total then needs no chain of earlier additions.1234stack limittotal[n]100000⋯evaluatorvisit statesread totalforcing depthIllustrative trace for n steps; the recorded run returns 100000.?EvaluateExplore code
Is forcing next.stats enough?The two field expressions, extracted from the operatorCodenext.stats.total = item.stats.total + 1; key = item.key + 1;Empty sourceKeys move forward; each total refers backkeystats.total00successorrefers to1stats.total[0] + 12stats.total[1] + 1computed numberdeferred expressionevaluated recordThree states shown; stats.total[k] is the stats.total of the state with key k.seq next.stats evaluates stats, not stats.totalkeystats.total00successorrefers to1stats.total[0] + 12stats.total[1] + 1computed numberdeferred expressionevaluated recordThree states shown; stats.total[k] is the stats.total of the state with key k.Each transition carries a computed numberkeystats.total00successorrefers to1122computed numberdeferred expressionevaluated recordThree states shown; stats.total[k] is the stats.total of the state with key k.?EvaluateExplore code
Each new history cell points to the old history.The new history field inside nextCodehistory = { head = item.key; tail = item.history; };The new cell retains the existing chainNew cellhead = item.keytail = item.historytailPrevious cellitem.historyshared, not copiedtailEarlier cells…Extending adds one cell. Deep traversal walks every link again.
How much of this state does each demand evaluate?next now carries a historyCodenext = { total = item.total + 1; history = { head = item.key; tail = item.history; }; };The same state, three demand boundariesDemandEvaluatesStill deferredseq nextthe recordtotal, historyseq next.totaltotalhistorydeepSeq nexttotal and every history cellnothingForcing a function value does not execute its body.
Repeated deep traversal revisits the growing prefix.A traversal of the whole retained chain after every extensionStep 1cell 11 visitStep 2cell 1cell 22 visitsStep 3cell 1cell 2cell 33 visits1 + 2 + 3 = 6 visitsn steps cost n(n + 1) / 2 visits: about 5 billion at n = 100000.
This interpreter deliberately demands its next state.nix-effects interpreterCodek = builtins.deepSeq newState (step.key + 1);nix-effects / src/trampoline.nix
The forcing policy should follow the state you carry.Handler statedeepSeq newStateA deliberately broad policy for carried state.Concrete syntax walkkey = item.key + 1;A different workload avoids unnecessary traversal.nix-effects / src/tc/eval/core.nix
This function remembers which function to call first.step wraps previous in a closureCodef0 = x: x; step = previous: x: previous x + 1;Constructing a function that remembers previousCallstep f0returnsA new functionx: previous x + 1remembers previous = f0
Each new function remembers the one before it.Three wrappers; no argument has been supplied to f3Codef1 = step f0; f2 = step f1; f3 = step f2;Arrows are captured references, not callsWrapperf3adds 1 after previouspreviousWrapperf2adds 1 after previouspreviousWrapperf1adds 1 after previouspreviousBase functionf0returns x
What does deepSeq do with this larger function?closureChain: the same construction, repeated 100000 timesCodebuiltins.deepSeq closureChain trueOne does not simplydeepSeq a function into doing its jobbuiltins.deepSeq closureChain true?EvaluateExplore code
What happens when we apply the larger function?Run the larger constructionCodeclosureChain 0f3 0 nests four calls before any additionf3 0 nests four calls before any additionEach call waits for the next one and owes an addition. Four calls are open when the base case returns zero; the replies then come back one, two, three. A large enough count reaches the stack limit before any call returns.1234stack limitf0 00f1 01f2 02f3 03callerexecution orderopen callsf3 0 nests four calls before any additionEach call waits for the next one and owes an addition. Four calls are open when the base case returns zero; the replies then come back one, two, three. A large enough count reaches the stack limit before any call returns.1234stack limitf0 00f1 01f2 02f3 03callerexecution orderopen callsIllustrative trace; spacing shows order, not elapsed time.closureChain 0?EvaluateExplore code
These pending additions can be represented as data.Preserve the pending operation and its orderFunction bodyx: previous x + 1previous is the remembered function. The pending operation is + 1.Explicit pending operation{ tag = "add"; amount = 1; }Store the operation for the dispatcher to perform. Written add 1 below.Where did previous go? Its links become the order of the frame list.First: f1's additionadd 1Then: f2's additionadd 1Last: f3's additionadd 1Defunctionalization
The dispatcher reads one frame and performs its operation.apply: the dispatcher for add framesCodeapply = value: frame: if frame.tag == "add" then value + frame.amount else throw "Unknown frame";Hand trace: one frameCurrent value0apply reads the frametag = "add"amount = 1New value1value + frame.amount
Do the frames produce the same result?frames holds the three add recordsCodebuiltins.foldl' apply 0 framesThe same computation, one explicit operation at a timeInitial value0add 1New value1add 1New value2add 1New value3f3 0?EvaluateExplore codebuiltins.foldl' apply 0 frames?EvaluateExplore code
A machine makes its pending operations inspectable.Each transition consumes one frame and carries the rest forwardEach transition consumes one frame and carries the rest forwardThe machine starts with value zero and three add-one frames. Each transition applies the top frame: zero becomes one, then two, then three. The remaining frames keep their order, and the run ends when no frames remain.pending framesvalueadd 1add 1add 10applyadd 1add 11applyadd 12applyno frames3Each transition consumes one frame and carries the rest forwardThe machine starts with value zero and three add-one frames. Each transition applies the top frame: zero becomes one, then two, then three. The remaining frames keep their order, and the run ends when no frames remain.pending framesvalueadd 1add 1add 10add 1add 11add 12no frames3Each transition consumes the top frame; the remaining frames keep their order.nix-effects / src/tc/eval/machine.nix
genericClosure keeps every visited state reachable from its result.The visited list holds every state until returnThe visited list holds every state until returnStates zero, one and two enter the visited list at successive steps. None is released, so the number of held states climbs and all three remain reachable when the driver returns.123s2s1s0drivertraversal stepsstates heldThe visited list holds every state until returnStates zero, one and two enter the visited list at successive steps. None is released, so the number of held states climbs and all three remain reachable when the driver returns.123s2s1s0drivertraversal stepsstates heldCounts states the driver references; references inside a state are not shown.Me waiting for the garbage collectorwhile holding every visited state
Keeping only the current state removes this history root.The driver holds one state at a timeThe driver holds one state at a timeState one replaces state zero, and state two replaces state one. The driver holds exactly one state throughout, and only state two remains when it returns.123s0s1s2drivertraversal stepsstates heldThe driver holds one state at a timeState one replaces state zero, and state two replaces state one. The driver holds exactly one state throughout, and only state two remains when it returns.123s0s1s2drivertraversal stepsstates heldCounts states the driver references; references inside a state are not shown.nix-effects / src/tc/eval/machine.nix
A native loop can run our steps without worklist keys or history.The borrowed machineryWhat the loop needsNested driver callsReturn from one step before calling the nextA fold needs a list of inputsLet each step choose whether to continueWorklist keys and visited historyCarry the current state; return the final resultThe step still chooses which fields to compute and which calls to make.
The step returns either another state or the final value.Proposed step protocol; nextState constructs one successorCodestep = s: if s.i == 100000 then [ false s.total ] else [ true (nextState s) ];Each reply gives control back to the native driverstep returnsThe native driver[ true nextState ]calls step with nextState[ false value ]returns value as the resulthsjobeki / NixOS/nix #14553 - open, unmerged - checked 22 September 2026
Our step still decides when to compute the carried total.The same targeted forcing policy, now inside nextStateCodenextState = s: let total = s.total + 1; in builtins.seq total { i = s.i + 1; inherit total; };The payload policy stays in our programCurrent statei = 0total = 0seq computes firsttotal = s.total + 1before the record is returnedNext statei = 1total = 1total is already a numberhsjobeki / NixOS/nix #14553 - open, unmerged - checked 22 September 2026
The builtin supplies the loop we kept borrowing.The step and payload policy have already been definedCodebuiltins.trampoline step { i = 0; total = 0; }The native driver replaces our borrowed worklistInitial statei = 0total = 0Native driverstep currenteach call returns a replyFinish reply[ false value ]the driver returns value[ true nextState ]: nextState becomes currentNo driver-owned list of every state. No keys for deduplication.hsjobeki / NixOS/nix #14553 - open, unmerged - checked 22 September 2026
Keep the loop small, stack-safe, and interruptible.CaseWhat I want the contract to sayThe step says finishNo further step is calledA million stepsThe driver stack does not grow with themOnly the final state is neededNo driver-owned collection of every stateThe loop never says finishAn interrupt still stops itThe step chooses which fields to force. Recursive callbacks still need explicit steps.
After all that, I would quite like a loop.Run the next step without growing the call stack.nixcon2026.bohinen.noExamples - captured evidence - reading - PDFsDiscuss builtins.trampoline: NixOS/nix #14553 / Prototype review / Probe resultssternenseemann: trampolining Nix (2022)