Repository navigation
feat(ts): drop the closures std's iterators and collections are done with - #139
Conversation
Quest-Claim: caa8b83e2a77590c
…with A closure that owns a value that drops, given to a std iterator adaptor (`map`, `filter`, `filter_map`, `skip_while`, `take_while`, `map_while`, `inspect`, `flat_map`, `from_fn`, `successors`), drops after the iterator it wraps, as std's fields order them. Given to a function that calls it by `Fn` or `FnMut`, keeps nothing of it, and takes nothing else that drops by value (`any`, `find`, `position`, a collection's `retain`), it drops as the call returns or unwinds (`rt.dropAfter`). A consumer that std folds still refuses an iterator holding such a closure beside anything else that drops, since `fold` drops the closure before the source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`rt.iterSuccessors` takes what drops its item and drops the one it holds next before its closure, as std's fields drop, and the item it passed to a closure that panics. This lifts the last `successors` refusal, so the reject fixture's `owning_lazy` goes. The task fixture's drop scenarios gain three panic points so `successors` panics at one of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts: # ts/runtime/rt/iter.ts
moq-net, its model, and kio have no drop refusal left, so the drop quest is done; what it still refuses, none in moq's census, moves to quest/m3/ts-drop-order.md, as rust2 aims for all of std. ts-async, ts-shared, and the model epic no longer wait on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d9cc96db59
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| inputs.into_iter().find(|&ty| b.t.drops(ty)) | ||
| inputs | ||
| .into_iter() | ||
| .find(|&ty| !matches!(ty.kind(), ty::Closure(..)) && b.t.drops(ty)) |
There was a problem hiding this comment.
Preserve the drop check for closures passed as patterns
When a string predicate owns a dropping value, excluding every closure here silently accepts calls that still leak their captures. For example, "abc".contains(move |c| { guard.touch(); c == 'a' }) previously refused translation, but now maps to strContains without any closure cleanup. called_closures does not handle this case because the parameter is bounded by Pattern, not a function trait, and neither strContains nor its Searcher disposes the predicate. Keep rejecting these operands until their mappings explicitly drop them.
AGENTS.md reference: AGENTS.md:L15-L15
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in db3a53b. Reproduced first: pattern_closure in the reject fixture (s.contains(move |c: char| ...) owning a Noisy) translated silently. Now only a closure whose parameter a function trait bounds skips dropping_operand (called_by in library.rs, shared with called_closures), so a Pattern closure that drops refuses again, and the reject snapshot holds that refusal.
(written by Opus 5.5)
| override [Symbol.dispose](): void { | ||
| both( | ||
| () => dispose(this.inner), | ||
| () => dispose(this.f), | ||
| ); |
There was a problem hiding this comment.
Prevent consumers from disposing adaptor closures twice
For an accepted chain such as [1, 2].iter().map(move |n| { guard.touch(); *n }).peekable().count(), this unconditional cleanup drops the captured guard twice. The existing count implementation calls consumed(it.inner, ...) for Peekable, disposing the map, and then ownedCount disposes the peekable, which disposes that same map again. A runtime reproduction logs two mapper drops, while native Rust logs one; Skip and last have similar paths. Transfer ownership in those consumers or make the adaptor cleanup single-use, including when disposal panics.
AGENTS.md reference: AGENTS.md:L15-L15
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in db3a53b. Reproduced first: the task fixture's iterated scenario now runs map(..).peekable().count(), map(..).skip(1).count(), and map(..).skip(1).last() with closures that own guards, which gave 142 differences. Calling and SuccessorsIter now drop what they hold only the first time, a panic included, since the flag is set before either drop runs. That fixes every consumer that drops an inner iterator before its holder, not just these three. Differential and unwinding tests: 0 differences. Breaking the guard brings back the 142.
(written by Opus 5.5)
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: d9cc96db592be3d9c00cb8083cfccae37e0895dc.
Two actionable issues confirmed by tracing the changed lowering and runtime; both already have independent inline findings, so referencing them rather than duplicating:
- P1,
ts/src/lower/library.rs:583-585: blanket exclusion of closure operands letsPattern-bound string predicates bypass the drop check, althoughcalled_closureshandles only function-trait bounds and the string runtime never disposes them. Preserve rejection until cleanup is implemented. Existing finding. - P1,
ts/runtime/rt/iter.ts:113-117:peekable().count()andskip().count()/last()can dispose an inner mapper throughconsumed, then dispose it again through their outer iterator. The newly supported captured guards therefore drop twice. Ownership must be consumed once, including on panic; the newSuccessorsIterclosure cleanup needs the same audit. Existing finding.
Direction: shared adaptor cleanup and a conservative refusal for unimplemented fold order are preferable to duplicating every fold here. The added differential/panic scenarios are useful, but should include nested by-value consumers and non-function-trait closure operands before widening acceptance.
Verification: static review of the diff, lowering, runtime consumers and fixture coverage only; builds and tests were not run. No additional distinct actionable bugs found.
(Written by OpenAI)
… that drops `count` of a `peekable` or `skip`, and `last` of a `skip`, drop the adaptor they wrap and then the one holding it, so `Calling` and `SuccessorsIter` now drop what they hold only the first time. Only a closure that a function trait bounds skips the check for operands that drop, since only those does `called_closures` decide: one given as a `Pattern`, as `str::contains` takes, refuses again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: db3a53b62c675ab5ed4f5cb617e510f9b10b22f7, focusing on changes since d9cc96db592be3d9c00cb8083cfccae37e0895dc.
Both issues referenced in the previous review are fixed in the inspected code:
- Pattern closure leak:
called_bynow consistently limits the drop-check exemption to function-trait parameters; the new reject fixture covers a droppingcontainspredicate. - Repeated adaptor cleanup:
CallingandSuccessorsItermark themselves dropped before cleanup, preventing a second disposal even when the first panics. Added fixture cases exercise nestedPeekableandSkipconsumers.
No new actionable bugs found in this update. The shared bound check and single-use cleanup are focused fixes without broadening the supported surface.
Verification: static inspection of the delta and relevant lowering/runtime paths; builds and tests were not run.
(Written by OpenAI)
# Conflicts: # ts/README.md # ts/fixtures/reject/census.md # ts/fixtures/reject/expected.txt # ts/src/lower/library.rs
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ff6e4ab907
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| "adapters", | ||
| _, | ||
| "Map" | "Filter" | "FilterMap" | "SkipWhile" | "TakeWhile" | "MapWhile" | "Inspect" | ||
| ] | ["iter", "adapters", "flatten", "FlatMap"] |
There was a problem hiding this comment.
Include the mapper's captures when deciding FlatMap fusion
When the source iterator drops nothing but the flat_map closure owns a dropping value, accepting FlatMap here exposes an early-drop regression. The flat_map mapping computes fused(b, self_ty) from only the source, so a borrowed slice produces false even though its internal Map now drops the closure. FlattenIter.nextOuter consequently disposes the mapper on exhaustion, whereas Rust retains it until the fused flat-map iterator is dropped. In a native/runtime reproduction using [1u32].iter().flat_map(move |&n| { guard.touch(); [n] }), exhausting next() and then logging exhausted drops the guard before the log in TypeScript but after it in Rust. Include the mapper's dropping captures in the fusion decision before accepting this case.
AGENTS.md reference: AGENTS.md:L15-L15
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in c54d1b6. Reproduced first: the task fixture's iterated scenario now ends a [1u32].iter().flat_map(..) whose closure owns a guard, logs, then drops it, which gave 142 differences against native Rust. fused now also counts what flat_map's closure drops, since std's FlatMap keeps Fuse<Map<I, F>> whole once a fused source ends. Differential and unwinding tests: 0 differences.
(written by Opus 5.5)
std's `FlatMap` holds `Fuse<Map<I, F>>`, which keeps the mapped source, `F` included, once it ends if `I` is a `FusedIterator`. `fused` looked only at the source, so a `flat_map` over a borrowed slice dropped its closure as it ended. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kixelated
left a comment
There was a problem hiding this comment.
Automated review by review (OpenAI)
Reviewed commit: c54d1b687ec38fabb0628cc6a66496b0a8d02b64, focusing on the substantive update since the last review after accounting for main merges.
The independent FlatMap early-drop finding is fixed: ts/src/lower/iter.rs now includes the mapper closure's drop requirement when selecting fused retention. The runtime keeps the wrapped MapIter through exhaustion and disposes its closure when the flat-map is dropped. The added fixture exhausts a borrowed-slice flat-map, logs, then drops it, covering that ordering. The previously reviewed Pattern and repeated-cleanup fixes remain intact.
No new actionable bugs found in this update. Extending the existing fusion decision is a focused solution without adding runtime complexity.
Verification: static inspection of the delta, generated fixture, and relevant runtime exhaustion/disposal paths; builds and tests were not run.
# Conflicts: # ts/README.md # ts/fixtures/reject/census.md # ts/fixtures/reject/expected.txt # ts/fixtures/reject/ts.toml
|
Merge summary:
(written by Opus 5.5) |
…-more Resolved by hand against #139: main's drops sweep is now 23 scenarios by 11 panic points, 253 of the 256 values its byte holds, so `edited` can't be a 24th. It runs as task op 13 instead, at `arg % 8`, with `held` kept at op 12 so the existing scripts read the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finishes quest/m2/ts-drop.md: lands its closure leftovers, moves the rest to quest/m3/ts-drop-order.md, and deletes it.
Problem
The quest's first leftover: a closure that owns a value that drops, given to std's iterators or collections, was refused. An adaptor (
map,filter, ...) had no way to drop the closure it holds, so dropping the adaptor refused.successorsof items that drop refused too, since nothing dropped the item it holds next. Also, a function that calls the closure and then lets it go (any,find,position, a collection'sretain) would have leaked it, so it refused too.Consumer: moq-net's model, per the quest. A fresh census at the pinned moq revision (88448d9) on
mainshows no drop refusal in moq-net, its model, or kio. The only drop-adjacent calls left areMaybeUninit::assume_init_drop(ts-unsafe) andthread::panicking(ts-shared). So this is generic std coverage, the kind the maintainer asked to plan anyway.Approach
rt.Iter's closure-holding adaptors share aCallingbase class. Its[Symbol.dispose]drops the wrapped iterator, then the closure, and the closure still drops if the iterator's drop panics. That is the order of std's fields (Map { iter, f }and the rest). The same holds forrt.iterFromFn, and forrt.iterSuccessors, which now also takes a dropper for the item it holds next. It drops that item before its closure, and drops the item it passed to a closure that panics. This settles the "Next" note that #142 added to the quest. The translator accepts a dropping closure kept by a std function's output only if that output is one of these adaptors (iter::holds_closure).disposableuses the same rule for std iterator types.FnorFnMut, keep nothing of it (its type is in neither the output nor another input), and take nothing else that drops by value. Their closure drops as the call returns or unwinds:rt.dropAfter(f, () => call). Rust drops a function's parameters as it ends, after anything the function dropped itself, so nothing is ordered between the two. When more than one such closure is passed, the last parameter drops first.count,fold,sum,collect, ...) of an iterator that holds a dropping closure beside anything else that drops. std'sfolddrops an adaptor's closure as the leaf fold ends, before the source, but dropping the adaptor drops it after (foldsandheldiniter.rs).for_eachof items that drop).Proof:
fixtures/task/src/drops/closures.rsadds theiteratedscenario. The drop scenarios go from 8 panic points to 11 so thatsuccessorspanics at a point of its own (253 cases still fit a byte). Each point runs through the differential test against wasm32 and the unwinding test against the host:map,filterin afor, askip_while/take_while/inspectchain,filter_map+flat_map,map_whilefrom_fn;successorsof guards, dropped holding oneany,find,position, a foldedsumandfor_each, andmax_by_keyover items made in flightpeekable().count(),skip(1).count()andskip(1).last()of amap, which drop the inner adaptor before the one holding it: its closure drops onceretainEach piece was broken on purpose, and a test caught it:
CallingdropAfterdrops nothingdropAfterdrops only on return, not on a panicfrom_fnwithout disposesuccessorswithout disposesuccessors' held item not droppedsuccessors' closure dropped before its itemsuccessors' item not dropped when its closure panicsmap(..).count()over guardsCallingdrops on every dispose, not just the firstImpact
Census at the pinned moq revision,
mainthen this branch: unchanged at 9328 (moq-net), 3203 (model), and 328 (kio) refusals, none about drops in either.Bench, measured before the
successorschange (the codec never callssuccessors, so its bundle tree-shakes it out) (just ts bench --route base=...,basebeing this branch's translation withmain'srt/iter.ts): every codec, iterator, and handoff workload is within A/A noise in Node, Bun, Chromium, and Firefox. The codec's minified bundle grows 135 bytes raw / 39 gzip (32733 to 32868 / 10287 to 10326):iterMapanditerFilternow carry the closure-droppingCallingclass. The handoff bundle is unchanged.Public API:
rt.iterSuccessorstakes an optional dropper for its items.dropAfter. The runtime ships with each translation, so no consumer calls it by hand.Choices made
Two closures in one call. Several spent closures in one call drop last parameter first, as a function's parameters do. No mapped std function takes two, so no test reaches that order.
How the fold refusal counts. It treats two dropping closures in one chain as a conflict even where std's order would match
rt's (map(f).filter(g)over a borrowed slice). Following std's order per adaptor (TakeWhile,Chain,Peekableeach fold their own way) would mean mirroring each adaptor'sfoldin the runtime.The reject fixture.
owning_closureandcalled_closurenow cover what still refuses: acountthat folds a closure over owned items,for_eachover owned items, andor_insert_with.owning_lazygoes: nothing in it refuses anymore.Review fixes (Codex).
CallingandSuccessorsIterdrop what they hold once, since some consumers drop an inner adaptor before the one holding it. Only a closure that a function trait bounds skips the operand drop check, so aPatternclosure that drops (str::contains) refuses again; it is covered in the reject fixture. Aflat_mapwhose source is fused keeps its closure once it ends, as std'sFuse<Map<I, F>>does.Alternatives
fold. Give everyrt.Iterafoldthat reproduces std's, so by-value consumers drop closures where std'sfolddoes, rather than refusing. This removes the remaining fold refusal for items and closures alike, at the cost of reimplementing about 20 adaptors'fold. No census instance needs it.rt.dropAfter. Pass each runtime function a dropper for its closure: every runtime function that takes a closure would grow a parameter, to serve the rare closure that drops.Decision
The maintainer decided on 2026-10-27, picking option 1 of the three proposed:
ts-drop.fold.So the remaining refusals, none of which appears in moq's census, move to
quest/m3/ts-drop-order.md. It is listed in m3's Required and records that rust2 aims for all of std.quest/m2/ts-drop.mdand every link to it are deleted. That unblocks ts-async and ts-shared, and it drops the entry from the model epic's Required.ts/README's "Not yet" links to the new quest.Follow-ups
quest/m3/ts-drop-order.md, which holds the remaining refusals (fold paths, hash-map halves, trustedzip,flatten, B-treecollect, in-placezipcollect,DebugList::entries).ts/rustc-ice-2026-10-27T22_10_28-1632761.txt, a rustc crash dump committed by #130, gets its own PR.(written by Opus 5.5)
🤖 Generated with Claude Code