Mika Bohinen / NixCon 2026

Trampolining Nix

I need to run the next step

Trampolining Nix

How I nearly lost my mind fighting the evaluator

Mika Bohinen - NixCon 2026

Charlie Kelly tries to explain a wall of papers connected by red string.Kleisli

I wanted algebraic effects in Nix. Running those programs led from recursive calls to trampolining, strictness, defunctionalization, and an explicit machine, with another evaluator problem after each apparent repair. I assume you know the Nix language; I will introduce the compiler terminology as we encounter it.

The program asks for state; the handler supplies it.

An effect is a request whose meaning is supplied by a handler
Code
get
An operation and the handler that answers it
Program asksgetrequest the state
Handler replies21the current state

I wanted algebraic effects in Nix. A program describes requests and a handler decides how to answer. get asks for the current state. For this small example, the handler holds 21 and replies with that number. All this happens during Nix evaluation.

bind passes that reply to the rest of the program.

A continuation: the function to run after get replies
Code
s: put (s * 2)
The continuation turns the reply into the next request
get repliess = 21
continuation
Next requestput (s * 2)
Handler storesstate = 42

The handler's reply becomes s. This function describes what to do next: request that the handler store twice s. A function representing the rest of the computation is called a continuation. bind connects an operation with that function.

The program changes the state, then returns the original value.

Read state, store twice the value, return the original
Code
bind get (s:
  bind (put (s * 2)) (_:
    pure s))
Follow the requests and replies
get repliess = 21
put storesstate = 42
pure s returns21the original value

The state ends at 42; the program returns 21.

nix-effects / README.md

The first bind gives get's reply to s. The second requests put (s * 2) and continues with pure s, the original value. The final handler state is 42; the program returns 21. The interpreter must carry both the changing state and these continuations until the program finishes.

Each recursive return still owes an addition.

Trace: recursive 3. Recorded run: n = 100000.
Code
recursive = count:
  if count == 0 then 0
  else 1 + recursive (count - 1);
recursive 3 nests four calls before any addition
recursive 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 calls

Illustrative trace; spacing shows order, not elapsed time.

recursive nerror: stack overflow (possible infinite recursion)Recorded result
Explore code

To compute recursive 3, we first need recursive 2 and then owe an addition; each inner call has the same dependency. The trace climbs four levels before anything returns. Once the base case returns zero, the replies come back as one, two and three. A sufficiently deep instance exceeds the available stack in this run. Nix's function-call-depth setting and the process stack are separate limits.

The driver calls one step.

Every step gives control back to the driver
Every 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 calls

The driver has called the first step. It is the only call above the driver.

The vertical axis counts open step calls above the driver, on the same scale and under the same stack limit as the recursive trace. The highlighted point is inside the first step. Horizontal distance shows execution order, not measured time.

That call returns before the next one begins.

Every step gives control back to the driver
Every 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 calls

The first step has returned its reply. Its call is gone before the next one starts.

The first call has returned and the trace is back on the driver baseline. Its reply, next, describes the next work; the driver has not called it yet.

More steps. The same nesting depth.

Every step gives control back to the driver
Every 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 calls

Same scale as the recursive trace: more steps, but the depth stays at one.

A trampoline is a driver that repeatedly runs one step. A step finishes with a value or returns a continuation: a function or data describing the next work. The driver invokes that work after the previous step has returned. These calls do not accumulate, though a step body can still recurse and values carried between steps can still be lazy.

A fold passes the running total to this function.

One addition per input
Code
total: _: total + 1
One call, starting from zero
Accumulatortotal = 0the initial value
total + 1
Next accumulator1
becomes total in the next call

The list only supplies iterations; the callback ignores its items. Starting from zero, each call adds one. n will be 100000.

A strict scalar fold already solves this fixed counter.

scalarFold, with n = 100000
Code
builtins.foldl'
  (total: _: total + 1)
  0
  (builtins.genList (i: i) n)
scalarFold100000Recorded result
Explore code

foldl' evaluates each new accumulator, and here the accumulator is the number itself. We call this result scalarFold. An effect program chooses its next request as it runs; a fixed list of inputs does not supply that stopping rule. The next example borrows a driver that lets each step choose its successor.

The operator chooses a successor state or ends the traversal.

genericClosure owns the loop; operator chooses the next work
StartstartSetthe initial records
For each new recordoperator itemreturns a list
No successors[]this branch stops
Successors[ successor ]queued if the key is new
Queued records are passed to operator in turn

The result lists every visited record. Keys prevent revisiting the same identity.

genericClosure visits records identified by unique keys, using this operator to produce one successor at each step until an empty list ends the traversal. It evaluates the operator's returned list and records and inspects their keys, while other fields can remain unevaluated.

Start with one record: an identity and a running total.

genericClosure input; total stands in for the handler's state
Code
startSet = [{ key = 0; total = 0; }];
One state, two jobs
key = 0

Identity used by the worklist

total = 0

Payload used by our counter

The interpreter must carry the handler's state between effect requests. This counter isolates that job: total carries our calculation, while key identifies a record for genericClosure. They happen to start at the same number, but have different jobs. startSet contains the initial record; the worklist uses keys to recognize records it has already visited.

Build the successor from the current record, item.

Inside the operator: the next total, then the successor record
Code
next = { total = item.total + 1; };
{ key = item.key + 1; inherit (next) total; }
The first call to operator
Current itemkey = 0total = 0the seed
operator
Successorkey = item.key + 1inherit (next) totalno addition has run yet

item is the record being visited; next holds the payload we want to carry forward. The successor advances the key and inherits total from next, which does not promise that the number has already been computed.

Return no successors when the key reaches the limit.

Operator excerpt; the else branch returns the successor in a list
Code
operator = item:
  if item.key >= n then [] else
The operator returns a list
Conditionoperator returnsEffect
item.key < n[ successor ]one more state
item.key >= n[]this branch stops

n = 100000. The seed has key 0; the last visited record has key n.

The operator receives one item and returns a list of successors. Below the limit it returns the one record we just built. At the limit it returns an empty list. In this example n = 100000, so the initial key is zero and the last visited key is n.

naive names the returned list, including the initial state.

Combine the seed and operator we just built
Code
naive = builtins.genericClosure {
  inherit startSet operator;
};
The returned list is named naive
First elementkey = 0the seed
Second elementkey = 1
…
Last elementkey = nselected by last naive

builtins.length naive and (last naive).total are the next two questions.

naive is our name for this first attempt. startSet and operator stand for the parts just introduced; the runnable example places them directly inside the genericClosure argument. Its result is a list of visited records, including the initial one. We will ask first for the list's length and then for the final record's total.

Surely the hard part is over.

A toddler clenches his fist with a triumphant expression.
Found a loop in Nix.

Time to ask it for a number.

We have a builtin to drive the traversal and a counter to put inside it. The next two experiments ask what this arrangement actually computes.

The same program succeeds or overflows depending on what I ask for.

naive is the list of states returned by my first attempt at a counter.

How many states were visited?
builtins.length naive100001Recorded result
Explore code
What is the final total?
(last naive).totalerror: stack overflow (possible infinite recursion)Recorded result
Explore code

Counting the visited states tells us nothing about whether their totals have been computed.

These results come from separate runs of the same program with n = 100000. Counting the visited states succeeds, but asking for the final total requires further evaluation and overflows the stack.

I had a rather generous definition of finished.

KC Green's dog calmly sits in a burning room.
The loop has finished.

I shall now make the mistake of asking for the answer.

I had taken the successful count to mean that the difficult part was over. The evaluator had been rather more precise about which part of the computation it had finished.

I need the answer, too

The key and the total have separate demand paths.

The two field expressions, extracted from the operator
Code
next.total = item.total + 1;
key = item.key + 1;
Keys move forward; each total refers back
keytotal
00
1total[0] + 1
2total[1] + 1
  • computed number
  • deferred expression
  • evaluated record
Three states shown; total[k] is the total of the state with key k.

Each state is a record whose key identifies its place in the traversal. Its total can remain a thunk. Nix can make the record available before evaluating that field. The stop condition, omitted here, ends the traversal when the key reaches n.

Counting visited states does not demand their totals.

The two field expressions, extracted from the operator
Code
next.total = item.total + 1;
key = item.key + 1;
Visiting evaluates each record and its key
keytotal
00
1total[0] + 1
2total[1] + 1
  • computed number
  • deferred expression
  • evaluated record
Three states shown; total[k] is the total of the state with key k.
builtins.length naive100001Recorded result
Explore code

Starting from key = 0 and total = 0, the traversal visits n + 1 states, including the initial record. Taking the length of the returned list gets us through that traversal without asking for the total fields inside its records.

The final total depends on earlier deferred additions.

The two field expressions, extracted from the operator
Code
next.total = item.total + 1;
key = item.key + 1;
Reading the final total climbs through every deferred addition
Reading 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 depth

Illustrative trace for n steps; the recorded run overflows at n = 100000.

(last naive).totalerror: stack overflow (possible infinite recursion)Recorded result
Explore code

Visiting the states returns after every step without evaluating a total. Evaluating the last total requires the previous total, which in turn requires the one before it, so forcing climbs one level per step. Following those dependencies through 100000 steps overflows the stack in this run.

Forcing the record leaves its total field deferred.

shallow: the same traversal, forcing next
Code
next.total = item.total + 1;
key = builtins.seq next
  (item.key + 1);
seq next evaluates the record, not its total
keytotal
00
1total[0] + 1
2total[1] + 1
  • computed number
  • deferred expression
  • evaluated record
Three states shown; total[k] is the total of the state with key k.
(last shallow).totalerror: stack overflow (possible infinite recursion)Recorded result
Explore code

seq next evaluates next only to weak head normal form: the outer record. Its total field stays untouched, so the chain of additions remains.

The next key becomes available after next.total has been computed.

The two field expressions, extracted from the operator
Code
next.total = item.total + 1;
key = builtins.seq next.total
  (item.key + 1);
First transition: compute 1, then expose key 1
keytotal
00
11
next?
  • computed number
  • deferred expression
  • evaluated record
Three states shown; total[k] is the total of the state with key k.

Because the worklist needs the next key, placing seq next.total in that expression makes it compute the total first. The first transition starts from the number 0 and computes 1 before returning key 1.

The following step receives a total that has already been computed.

The two field expressions, extracted from the operator
Code
next.total = item.total + 1;
key = builtins.seq next.total
  (item.key + 1);
Each transition carries a computed number
keytotal
00
11
22
  • computed number
  • deferred expression
  • evaluated record
Three states shown; total[k] is the total of the state with key k.

The preceding state already holds the number 1, so the following transition can compute 2 without following a chain of earlier additions. The same relationship holds between each pair of successive states.

Asking for the final total now succeeds with the same input and limits.

targeted: the same traversal, forcing next.total
Code
next.total = item.total + 1;
key = builtins.seq next.total
  (item.key + 1);
Each step computes its total before the next begins
Each 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 depth

Illustrative trace for n steps; the recorded run returns 100000.

(last targeted).total100000Recorded result
Explore code

The input and evaluator limits are unchanged, but each step now computes the total it carries forward, one addition deep. Asking for the final total needs no chain and returns 100000. The handler has the same distinction to manage: making its next state record available does not establish that the fields inside it have been computed.

Is forcing next.stats enough?

The two field expressions, extracted from the operator
Code
next.stats.total = item.stats.total + 1;
key = item.key + 1;
Keys move forward; each total refers back
keystats.total
00
1stats.total[0] + 1
2stats.total[1] + 1
  • computed number
  • deferred expression
  • evaluated record
Three states shown; stats.total[k] is the stats.total of the state with key k.

The total now lives inside stats, so consider whether evaluating the containing record also computes the scalar needed by the next step.

The stats record can still contain a deferred total.

nestedShallowResult: final total after forcing stats
Code
next.stats.total = item.stats.total + 1;
key = builtins.seq next.stats
  (item.key + 1);
seq next.stats evaluates stats, not stats.total
keystats.total
00
1stats.total[0] + 1
2stats.total[1] + 1
  • computed number
  • deferred expression
  • evaluated record
Three states shown; stats.total[k] is the stats.total of the state with key k.
nestedShallowResulterror: stack overflow (possible infinite recursion)Recorded result
Explore code

Although the containing record is available, its total field still depends on the preceding state's scalar, leaving the same addition chain to evaluate later.

Computing next.stats.total at each step breaks the nested dependency chain.

nestedTargetedResult: final total after forcing stats.total
Code
next.stats.total = item.stats.total + 1;
key = builtins.seq next.stats.total
  (item.key + 1);
Each transition carries a computed number
keystats.total
00
11
22
  • computed number
  • deferred expression
  • evaluated record
Three states shown; stats.total[k] is the stats.total of the state with key k.
nestedTargetedResult100000Recorded result
Explore code

This repair reaches the nested scalar before returning the next key, allowing the following transition to begin with the number it needs already computed.

Perhaps I can force everything

Why not force the whole state?

A girl smiles at the camera while a house burns behind her.
I'll force the whole state.

How much work can that be?

An interpreter can carry considerably more state than our counter, so forcing all of it may involve traversing a much larger structure at every step.

Each new history cell points to the old history.

The new history field inside next
Code
history = {
  head = item.key;
  tail = item.history;
};
The new cell retains the existing chain
New cellhead = item.keytail = item.history
tail
Previous cellitem.historyshared, not copied
tail
Earlier cells…

Extending adds one cell. Deep traversal walks every link again.

head stores the current key. tail points to the preceding history, so the new cell retains the old chain. Extending the history adds one cell; walking the entire history follows the new link and all the older links. Previously computed cell values can be shared while those links still have to be visited.

Forcing a record, a field, or the whole structure does different work.

next now carries a history
Code
next = {
  total = item.total + 1;
  history = { head = item.key; tail = item.history; };
};
The same state, three demand boundaries
DemandEvaluatesStill deferred
seq nextthe recordtotal, history
seq next.totaltotalhistory
deepSeq nexttotal and every history cellnothing
Forcing a function value does not execute its body.

Evaluating seq next reaches the outer record, whereas seq next.total reaches the number stored in its total field. Using deepSeq next also traverses the history and other reachable data. Function values can become available during that traversal without their bodies being executed.

Repeated deep traversal revisits the growing prefix.

A traversal of the whole retained chain after every extension
Step 1
cell 1
1 visit
Step 2
cell 1cell 2
2 visits
Step 3
cell 1cell 2cell 3
3 visits

1 + 2 + 3 = 6 visits

n steps cost n(n + 1) / 2 visits: about 5 billion at n = 100000.

As the chain grows from one cell to two and then three, walking it after every extension requires 1 + 2 + 3 cell visits. After N extensions, that sum is N(N + 1)/2.

This interpreter deliberately demands its next state.

nix-effects interpreter
Code
k = builtins.deepSeq newState (step.key + 1);

nix-effects / src/trampoline.nix

Each effect can update the handler state and choose the computation to run next. The nix-effects interpreter uses a builtin worklist to drive those transitions. Its next key deliberately demands the new handler state. That dependency makes the state available before the driver advances. The smaller counter isolates why advancing the driver is not enough to compute the state it carries.

The forcing policy should follow the state you carry.

Handler state

deepSeq newState

A deliberately broad policy for carried state.

Concrete syntax walk

key = item.key + 1;

A different workload avoids unnecessary traversal.

nix-effects / src/tc/eval/core.nix

The handler interpreter forces its new state broadly. The concrete syntax walk already has the fields it needs, so traversing the whole structure again would add unnecessary work. Their different choices reflect the states they carry.

Now the functions are the problem

This function remembers which function to call first.

step wraps previous in a closure
Code
f0 = x: x;
step = previous: x: previous x + 1;
Constructing a function that remembers previous
Callstep f0
returns
A new functionx: previous x + 1remembers previous = f0

step wraps previous in a closure that calls previous x and still owes an addition after that call returns. These are the same kind of callbacks that carry the rest of an effect program.

Each new function remembers the one before it.

Three wrappers; no argument has been supplied to f3
Code
f1 = step f0;
f2 = step f1;
f3 = step f2;
Arrows are captured references, not calls
Wrapperf3adds 1 after previous
previous
Wrapperf2adds 1 after previous
previous
Wrapperf1adds 1 after previous
previous
Base functionf0returns x

The arrows follow the captured previous references: f3 remembers f2, which remembers f1, which remembers f0. The larger example, closureChain, repeats the same construction 100000 times using a strict fold. We have constructed functions; we have not yet asked the final function to produce a number.

A function value can be ready while its body still has work.

closureChain: the same construction, repeated 100000 times
Code
builtins.deepSeq closureChain true
Boromir raises a hand to explain the difficulty.
One does not simply

deepSeq a function into doing its job

builtins.deepSeq closureChain truetrueRecorded result
Explore code

deepSeq can make the function value available without applying it to an argument. The large construction succeeds in this run, but its body still contains the chain of calls. The next experiment asks for a number from it.

Applying the function enters the remembered calls.

Run the larger construction
Code
closureChain 0
f3 0 nests four calls before any addition
f3 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 calls

Illustrative trace; spacing shows order, not elapsed time.

closureChain 0error: stack overflow (possible infinite recursion)Recorded result
Explore code

Applying f3 to zero calls f2, then f1 and f0, with an addition still to perform after each return. These three additions produce three, but applying the larger closure chain exceeds the available stack because its pending work is still inside the function bodies.

The recursion has moved inside the callback.

Pikachu stares in open-mouthed surprise.
I forced the function.

And then I called it.

Forcing the function value did not run its body. The driver cannot schedule the nested calls still hidden inside that body; the next repair makes their pending work explicit.

These pending additions can be represented as data.

Preserve the pending operation and its order
Function bodyx: previous x + 1

previous 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 1
Then: f2's additionadd 1
Last: f3's additionadd 1
Defunctionalization

The trampoline cannot schedule calls hidden inside a callback. In this example the function captures previous; one is the literal amount to add after that call returns. A frame records that pending addition as an add tag and an amount. The links through previous become an ordered list called frames. The record preserves the operation; the list preserves its order. A dispatcher will perform these operations. This transformation is called defunctionalization.

The dispatcher reads one frame and performs its operation.

apply: the dispatcher for add frames
Code
apply = value: frame:
  if frame.tag == "add"
  then value + frame.amount
  else throw "Unknown frame";
Hand trace: one frame
Current value0
apply reads the frametag = "add"amount = 1
New value1value + frame.amount

One call handles one pending operation. The small diagram traces an input of zero with one add-one frame; it is not the result of running the full list.

The dispatcher performs the same three additions.

frames holds the three add records
Code
builtins.foldl' apply 0 frames
The same computation, one explicit operation at a time
Initial value0
add 1
New value1
add 1
New value2
add 1
New value3
f3 03Recorded result
Explore code
builtins.foldl' apply 0 frames3Recorded result
Explore code

Starting from zero, foldl' applies the three add frames in order. Compare that run with f3 0. A strict fold handles this fixed list, while a larger machine can choose transitions dynamically. Recursive helpers inside a dispatcher still need their own analysis.

A machine makes its pending operations inspectable.

Each transition consumes one frame and carries the rest forward
Each 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 frames3

Each transition consumes the top frame; the remaining frames keep their order.

nix-effects / src/tc/eval/machine.nix

Our small machine state contains a value and a stack of pending frames. Each transition takes the top frame, performs its operation and carries the remaining frames forward, until no frames remain. An abstract machine makes that state and its transition rules explicit. The linked type-checker evaluator uses richer frames: for example, KApp1 remembers an environment and a pending argument. The diagram stays with our three additions so the new idea does not require learning that evaluator's language.

This has become a disproportionate response to addition.

The hijacker in Captain Phillips points to his eyes while taking command.
Look at me.

I'm the evaluator now.

We began with effect callbacks and now have explicit frames, a dispatcher and machine state. Those pieces expose the work to the driver. Next we return to the borrowed driver and ask what it keeps after that work is finished.

Why am I keeping every state?

genericClosure keeps every visited state reachable from its result.

The visited list holds every state until return
The 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 held

Counts states the driver references; references inside a state are not shown.

A skeleton waits at a computer.
Me waiting for the garbage collector

while holding every visited state

The three-frame example used a fold. Return now to genericClosure, the driver borrowed for our counter: its result list keeps every visited record reachable after the arithmetic has finished. This archive belongs to that driver; an abstract machine does not inherently need it. The garbage collector cannot reclaim objects while live references still reach them. The records' payloads can retain further data, so completing the calculation does not by itself release everything used to obtain the answer.

Keeping only the current state removes this history root.

The driver holds one state at a time
The 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 held

Counts states the driver references; references inside a state are not shown.

nix-effects / src/tc/eval/machine.nix

A driver that returns only its current state can avoid keeping this full history list; the public evaluator uses chunked strict folds for that purpose. We still need to inspect values inside the current state, since they can hold references to earlier data.

What a native trampoline should promise

A native loop can run our steps without worklist keys or history.

The borrowed machineryWhat the loop needs
Nested driver callsReturn from one step before calling the next
A fold needs a list of inputsLet each step choose whether to continue
Worklist keys and visited historyCarry the current state; return the final result

The step still chooses which fields to compute and which calls to make.

A simple native loop could have avoided the keys, worklist history and fold scheduling needed to borrow iteration from other builtins. The recursive example asks for dynamically chosen steps whose driver stack does not grow with their count. The deferred-field and deep-traversal examples ask for an explicit shallow forcing boundary, with the program choosing which carried fields must be computed. The retained-history example asks for a driver that returns the final result without maintaining a visited-state collection. These are requirements derived from our examples; they do not imply that arbitrary callback recursion or retained references inside user state disappear.

The step returns either another state or the final value.

Proposed step protocol; nextState constructs one successor
Code
step = s:
  if s.i == 100000
  then [ false s.total ]
  else [ true (nextState s) ];
Each reply gives control back to the native driver
step returnsThe native driver
[ true nextState ]calls step with nextState
[ false value ]returns value as the result

hsjobeki / NixOS/nix #14553 - open, unmerged - checked 22 September 2026

The proposed native loop calls step with the current state. A two-element reply begins with true to continue or false to finish. Its second element is the next state or the result. Here i counts iterations for our stopping rule; unlike a genericClosure key it is not an identity for deduplicating visited records.

Our step still decides when to compute the carried total.

The same targeted forcing policy, now inside nextState
Code
nextState = s:
  let total = s.total + 1;
  in builtins.seq total {
    i = s.i + 1; inherit total;
  };
The payload policy stays in our program
Current statei = 0total = 0
seq computes firsttotal = s.total + 1before the record is returned
Next statei = 1total = 1total is already a number

hsjobeki / NixOS/nix #14553 - open, unmerged - checked 22 September 2026

nextState adds one to the total before returning the successor record. The native loop's shallow boundary makes a record available but does not compute its fields. i remains our application's stop counter. No visited-state identity or history collection is needed by this driver.

The builtin supplies the loop we kept borrowing.

The step and payload policy have already been defined
Code
builtins.trampoline step { i = 0; total = 0; }
The native driver replaces our borrowed worklist
Initial statei = 0total = 0
Native driverstep currenteach call returns a reply
Finish reply[ false value ]the driver returns value
[ true nextState ]: nextState becomes current

No driver-owned list of every state. No keys for deduplication.

hsjobeki / NixOS/nix #14553 - open, unmerged - checked 22 September 2026

NixOS/nix PR #14553 is an open, unmerged proof of concept by hsjobeki, checked on 22 September 2026 at commit 85bf7b5790da5ca182a6f82979d99aa10fc058a2. Its interface is trampoline step initial. Its C++ loop forces the initial and subsequent states to weak head normal form, the reply list and continuation boolean, and the terminal result to weak head normal form. It has no visited-state list or key deduplication. This is a proposed interface, not a released builtin.

Keep the loop small, stack-safe, and interruptible.

CaseWhat I want the contract to say
The step says finishNo further step is called
A million stepsThe driver stack does not grow with them
Only the final state is neededNo driver-owned collection of every state
The loop never says finishAn interrupt still stops it

The step chooses which fields to force. Recursive callbacks still need explicit steps.

My preferred contract is an explicit continue-or-finish protocol, a shallow state boundary, no implicit visited history, and clear errors and interruption behavior. It should document the demand on the initial state and final result as carefully as the demand on intermediate states. The step should be able to force total while leaving unrelated metadata lazy; automatically deep-forcing everything would change that behavior and traverse growing structures. A final result may have a different type from the intermediate state. Tests should cover early stop, long scalar and record runs, unused erroring fields, function-valued results, malformed replies, and cancellation. Bounded driver stack does not bound recursive work inside a callback or memory retained by the state. In the local probe, the copied prototype's constant-state loop required SIGKILL after SIGINT. A separate copy with an explicit interrupt check provides the control; the research report records the results and evaluator version. The PR is a useful starting point; these are proposed acceptance criteria based on the examples, not claims of maintainer consensus.

After all that, I would quite like a loop.

Run the next step without growing the call stack.

nixcon2026.bohinen.no

Examples - captured evidence - reading - PDFs

Discuss builtins.trampoline: NixOS/nix #14553 / Prototype review / Probe results

sternenseemann: trampolining Nix (2022)

I started with algebraic effects and ended up building machinery to obtain ordinary iteration. A simple builtin loop that runs each next step without growing the driver call stack could have spared much of that work. The native proposal makes that missing primitive concrete; the examples show its useful behavior and the boundaries it should document. Thank you to sternenseemann for the original genericClosure trampoline discussion, hsjobeki for the native prototype, and the nix-effects contributors.

Worked example

The fold returns an attribute set. Ask for its names without demanding the value of total.

Definitions · example.nix
n = 100000;
ticks = builtins.genList (i: i) n;
result = builtins.foldl'
  (acc: _: { total = acc.total + 1; })
  { total = 0; } ticks;

Empty source

    Inspect the code, then run the expression.

    Result
    Not run
    Reference