or_insert_with lambda is refused (NK2105), though the lambda runs at most onceOpen and answer this issue on GitHub.
ready · opened 2026-10-29 · updated 2026-10-29 · 2 comments
Found while building #950 step 7 (b950d report). Site: tools/sharing.nika 510.
Probe
let t: Vec[i64] = [1]
m.entry("k").or_insert_with(fn() { H { a: "x", t: t } })
gives NK2105 (a value moved into a lambda). or_insert_with calls its lambda at most once, so the move is sound.
Decided (owner, 2026-10-29): ADR-lambdas-1, spec Part I 5.4 D (and the NK2105 note in 6.5), Part II 12.2 access / 12.7 task::scope, Part III contract key once (commit 288a993a). A lambda handed to a parameter that runs it at most once may give away what it captures. For std, the contract says once. For user code it is inferred, and there is no marker.
Step (a): std (ADR-lambdas-1 D1, D2, D4)
crates/nikaia-std/std.contracts: key once = ["f"] (["body"] for task::scope) on Entry::or_insert_with, Entry::and_modify, task::scope, Locked::access, SharedMut::access, and nowhere else (not update). The contract reader carries the field.once parameter, the take walk does not push the repeat frame for it (check_state.nika:328, what: "lambda", read in check_takes.nika hands_over_path_of / around_a_repeat). A value handed over inside it counts as gone after the call. A loop around the call stays a repeat.fold_bound.nika:1153: the hard-coded once name list reads the contract field instead.map may run its lambda more than once).tests/compiler/ (Nikaia, with .stdout/.stderr): or_insert_with moving a captured value is accepted and a later use is NK2105. The same move under map stays NK2105 with the D4 wording. update stays refused.tools/sharing.nika 510 (Sharing::note) to one entry(key).or_insert_with(...) that moves ty (commit says Part of #950).Step (b): user code (D3), after (a)
once.impl FnOnce (emit_core_types.nika:226). Kept stays Kept<dyn Fn>.entry accepting the move. A callee that calls f twice gives NK2105 at the caller, naming that call.The commit that builds step 10 writes Fixes #973.
A lambda (fn { … }, Part I 5.3) may use names from around it. Part I 5.4 decides how without anything written: if the function that takes the lambda only runs it before it returns (map), the lambda borrows them. If it keeps it (spawn), the lambda moves them. There is a third case nobody has decided yet. The lambda runs during the call, as an immediate one does, but it wants to give away a captured value: store it in a struct, push it, hand it to a keeper.
let t: Vec[i64] = [1]
m.entry("k").or_insert_with(fn() { H { a: "x", t: t } })
Today (checked with target/fast/nikaia check on develop 50e18063):
error[NK2105]: t is stored in H.t inside a lambda, but it was declared outside it. The reason the checker gives is “a lambda may run more than once, and the second run would find it gone”. That is true of map. It is false of or_insert_with, which runs its lambda at most once. The program’s way out is or_insert(H { .. }). That builds the value, with its copies, on every call, including the calls where the key is already there.
Where it comes from: the #950 step 7 builder (b950d report), at tools/sharing.nika 510 (Sharing::note). There, if !self.handles.contains_key(key) { self.handles.insert(key.clone(), Handle { function: function.clone(), value: value.clone(), ty: ty, .. }) } wants to become one entry(key).or_insert_with(...). The lambda moves ty (an owned parameter) into the new Handle. With or_insert instead, the call would copy function and value every time, so the double lookup stays. This is a real site, not bookkeeping. Rust’s own or_insert_with takes an FnOnce for exactly this reason.
What is already decided: Part I 5.4 C: the context of a user code parameter is inferred, not written; there is no @detached to write. ADR-collections-1 D1: the or_insert_with lambda runs during the call. No ADR speaks of a lambda that is called at most once, so this is kind 2. The owner’s lean (2026-10-29): allow it for the std parameters that are called at most once, and maybe go further.
std.contracts describes a lambda parameter only by its type (f: fn()). There is no field for how often it runs. One analysis already needs this fact and writes it as a list of names: fold_bound.nika:1153 (let once = name == "and_modify" || name == "or_insert_with" || ...).check_takes.nika hands_over_path_of → around_a_repeat. Every lambda pushes a repeat frame (check_state.nika:328, what: "lambda"), the same as a loop does.FnOnce: Entry::or_insert_with and and_modify (Rust std), task::scope (task.rs:1418), and Locked/SharedMut::access (lock.rs:294, :450). So for std, the lowering changes nothing: rustc infers that the closure moves t and accepts it.impl Fn(..) (emit_core_types.nika:226), and kept ones as Kept<dyn Fn> (:219).The std lambda parameters, by how often they run (from std.contracts and Part II):
| Runs at most once | Runs more than once, or may |
|---|---|
Entry::or_insert_with f, Entry::and_modify f, task::scope body, Locked::access f, SharedMut::access f |
every Seq and ListExt entry (map, filter, fold, …), Vec::retain, Vec::sort_by_key, Par::fold (all three), SharedMut::update and Locked::update (the block runs again if another task got there first, Part II 12.2), SharedMut::supervised build (once per restart), net::serve handler |
task::Scope::spawn, supervisor::child and net::serve keep their lambda, so they already move what it captures (NK2101’s rule). update shows that the row cannot be guessed from the name. It is a fact about each entry.
(a) std only. The contract of a lambda parameter gets one field, once = true, set on the five entries in the left column. When a lambda is handed to such a parameter, the take walk does not count it as a repeat. A value moved inside it is gone after the call, as an argument would be. The lambda stays a function of its own for return and the rest.
(b) The same for user code, inferred. A code parameter of a user function is once when its body calls it at most once on every path and hands it on only to a once parameter. A call inside a loop, two calls, or keeping it makes it many. This is the take walk the checker already runs, with “calling f” counted as “handing f over”: a second call is exactly NK2105’s “used again”. The answer goes into the ledger beside kept and lent (6.7) and is lowered as impl FnOnce. Nothing is written in the source.
(b’) The same, with a written marker (f: once fn(), or a @once attribute).
(c) Keep refusing it. or_insert with the copy, or the double lookup.
| Simple as a scripting language | Rust lowering | Checker effort | What self-hosting gains | If it is wrong | |
|---|---|---|---|---|---|
| a | nothing to learn: the program that reads right is accepted | none (counterparts are FnOnce) |
a contract field, one test in the repeat frame, fold_bound’s name list reads the field |
sharing.nika 510 and every entry site that builds an owned value, with no copy and no second lookup |
a wrong once row would let a retried lambda find a value gone: so the column is short and each row cites the spec sentence |
| b | nothing to learn, same as (a) | impl FnOnce for once parameters, impl Fn stays for the rest |
the walk exists; a ledger column and a fixpoint over calls, as for kept and lent | user helpers that wrap entry or a lock work like std’s |
a callee edited to call f twice turns many, and its callers that moved into the lambda get NK2105 at their call. This is the same fragility kept/lent inference already has, and the message names the second call in the callee |
| b’ | a new word that every reader meets and that answers a question the compiler can answer itself | the same | parser, formatter, LSP, plus a check that the marker is true | the same as b | contradicts 5.4 C (inferred, not written) |
| c | the program reads wrong (a copy for nothing) | - | none | nothing; the double lookup stays | - |
Is (a) now and (b) later a clean path? Yes. The field (a) adds to std.contracts is the same fact (b) would write into the ledger for user functions. (b) only adds the part that computes it. No rule of (a) changes when (b) comes. Nothing in (a) closes a door, and (b’) is not needed for (b).
Decide (a) and (b) together, in one ADR. Build (a) first. Reject the written marker. A marker is a word that a scripting-language user should never have to know, for a fact the body already shows. Inference is the rule 5.4 C already chose for immediate and detached.
ADR-lambdas-1: A lambda that runs at most once may give away what it captures D1. A lambda handed to a parameter that runs it at most once may hand a captured value to something that keeps it. After the call the value is gone, as an argument’s is (
NK2105on a later use). A lambda handed to any other parameter keeps today’s rule. D2. Instdthese parameters areEntry::or_insert_with,Entry::and_modify,task::scope,Locked::accessandSharedMut::access, each markedoncein its contract.updateis not one (it may run again, Part II 12.2). D3. A code parameter of user code is once when the body calls it at most once on every path and hands it on only to a once parameter. This is inferred, written to the ledger beside kept and lent, and has no syntax. It is lowered asimpl FnOnce. A function value that is kept (a field, alet, a result) is never once. D4. The refusal names why:mapmay run its lambda more than once, or, for user code, the second call in the callee. Build order: D1, D2, D4 (with thesharing.nika510 move); then D3.
Generated by Claude Code
Owner’s decision (2026-10-29): the paper’s recommendation is accepted.
std that runs at most once gets a once fact in std.contracts.Generated by Claude Code