skip to content

Your iter.Seq2 file iterator opens a file it reads from; how do you guarantee it closes when the caller breaks early?

level: seniorimportance: should knowfreq 45%

answer

  1. two functions, two very different lifetimes
  2. the closure's frame spans the loop
  3. open inside the closure, not the factory
  4. unwinding runs the defers on break too
  5. per-element defers pile handles up

basics

~20 s

Open the file inside the returned closure and defer its Close there, never in the factory. That closure's frame lasts exactly as long as the loop and unwinds on an early break, so Close runs once.

solid answer

~50 s

Put the `os.Open` and the `defer f.Close()` inside the closure the factory returns. That closure is the function that runs for the whole loop, so its frame is the only scope whose lifetime matches the iteration. When the caller breaks, the next `yield` returns false, your guard returns, the frame unwinds and the deferred Close runs immediately — the same is true when the caller returns out of the loop or its body panics, because the runtime unwinds through the iterator's frame. Two traps remain. Opening in the factory instead is wrong: the file opens even for a sequence nobody ranges, and there is no frame to hang the cleanup on. And deferring a per-element `Close` inside the iterator's own loop holds every handle open until the whole walk ends; close each one before the next yield, or hand the caller something it owns and document that. Then prove it with a test that breaks on the second item and asserts the release happened once.

code

go · 19 lines
go
func Lines(path string) iter.Seq2[string, error] {
	return func(yield func(string, error) bool) {
		f, err := os.Open(path)
		if err != nil {
			yield("", err)
			return
		}
		defer f.Close() // runs on break, on an outer return and on a panic
		sc := bufio.NewScanner(f)
		for sc.Scan() {
			if !yield(sc.Text(), nil) {
				return
			}
		}
		if err := sc.Err(); err != nil {
			yield("", err)
		}
	}
}

go deeper

for a junior

Remember that the file is opened inside the returned closure with defer Close right after it, never in the outer function that builds the sequence.

for a middle

Explain when the closure's frame ends: on exhaustion, on the return after yield reports false, and on a panic or outer return that unwinds through it. The defers run in every one of those.

for a senior

Prove it rather than assert it, with a test that breaks early and asserts the resource was released exactly once, and enforce a rule against per-element defers inside the production loop.

for a principal

Decide who owns a per-element resource in your public API: the library that closes it before the next value, or the caller you hand it to. Write that promise into the doc comment before anyone imports it.

## The scope that matches the loop A push iterator has two functions in it: the factory that builds the sequence, and the closure the factory returns. They have completely different lifetimes. ``` func Lines(path string) iter.Seq2[string, error] { // returns instantly return func(yield func(string, error) bool) { // runs for the whole loop ... } } ``` The factory returns before the caller has even started ranging. The closure runs from the first element to the last. So the closure's frame is the only scope in the whole design whose lifetime is "the duration of the loop", and that is where a resource the loop needs must be acquired and deferred. ``` return func(yield func(string, error) bool) { f, err := os.Open(path) if err != nil { yield("", err) return } defer f.Close() ... } ``` Opening in the factory instead has two defects. The file opens as a side effect of merely constructing the sequence, even if nobody ranges it, which breaks the laziness callers assume. And there is nothing to defer against: the factory returns immediately, so a `defer` there would close the file before the first element is ever produced. ## Why break does not skip the cleanup The worry people have is that the caller's `break` somehow abandons the iterator mid-flight, the way abandoning a generator would in some languages. It does not, provided your iterator honours the protocol. Walk the three exits: **Exhaustion.** Your production loop ends, the closure returns normally, the deferred Close runs. Unremarkable. **`break` (or the loop body finishing early any other way).** The generated `yield` returns false. Your `if !yield(...) { return }` guard returns. The frame unwinds and the deferred Close runs. Note that this only works because you checked the bool: an iterator that ignores it does not return at that point, and its cleanup is delayed until it has finished producing values nobody will read. **A non-local exit or a panic in the loop body.** If the caller `return`s out of the enclosing function from inside the loop, or the body panics, the runtime unwinds the loop body *through* the iterator's frame. Deferred calls in the iterator run as part of that unwinding. So `defer f.Close()` inside the closure is a genuine guarantee across all three exits, and it runs exactly once because a frame unwinds once. That is the property worth stating in an interview: in a push iterator, `defer` inside the returned closure is a real cleanup guarantee, not a best-effort one — as long as you return when yield says false. ## The per-element trap One open resource for the whole loop is easy. The mistake is per-element: ``` for _, e := range entries { f, err := os.Open(e) ... defer f.Close() // WRONG: one live handle per entry if !yield(read(f)) { return } } ``` Every deferred Close is registered against the *closure's* frame, which does not return until the entire walk ends. On a tree of ten thousand files you exhaust the process's file descriptors long before the loop finishes. The fixes are the ordinary ones: close explicitly before moving on, or move the per-element work into its own small function so its `defer` fires per call, or restructure so the value you yield does not need the handle any more. The third option is a design decision rather than a fix: yield the open `*os.File` itself and document that the caller owns closing it. That can be the right API for a library whose whole job is handing out readers, but it is a promise you are making to every importer, and it fails badly if a caller breaks out of the loop holding one. ## Proving it Assertion is not enough here, because the bug only appears for callers who stop early — the exact case nobody exercises by accident. The test is small: 1. Build the sequence over a fixture with several entries. 2. Range over it and `break` on the second element. 3. Assert that the release ran, that it ran exactly once, and that the counter does not move afterwards. Counting releases matters as much as observing one: an iterator that closes in both a `defer` and an explicit path double-closes, and a double `Close` on a file returns an error most code discards. Adding a second case that returns out of the enclosing function from inside the loop covers the non-local exit. ## The review checklist For any hand-written push iterator that touches a resource, four questions: is the resource acquired inside the returned closure rather than the factory; is its release deferred there; is every `yield` result checked so the return actually happens; and is anything deferred inside the production loop rather than around it. Those four catch the leaks. The fifth question is the design one — if a per-element resource escapes to the caller, is that written down in the doc comment.

  • What if you open a file per entry rather than one for the whole walk?
    Do not defer inside the production loop: those Close calls are registered against the closure's frame and only run when the entire walk ends, so handles accumulate until you run out. Close explicitly before the next yield, move the per-entry work into its own function so its defer fires per call, or hand the caller the open file and document that they own closing it.
  • The consumer's loop body panics on the third element. Does the iterator's deferred Close still run?
    Yes. The panic propagates out of the yield call and unwinds the iterator's frame on its way, so deferred calls there run before it reaches the caller. That is the same mechanism that makes an outer `return` from inside the loop safe, and it is why a defer in the closure is a real guarantee rather than a best-effort one.
  • How do you prove cleanup ran when the caller breaks on the second item?
    Write a test that ranges over the sequence with a `break` on the second element, then assert against a counting stand-in for the resource: released exactly once, and the count does not move afterwards. Counting matters as much as observing, because a stray explicit close plus the deferred one is a double release that most code silently discards.
  • Why not just document that callers must call a cleanup function after the loop?
    Because they will forget, and nothing in the loop shape reminds them; the resource then lives until the process ends. The iterator already has a frame whose lifetime is exactly the loop, so it can clean up without the caller's help. Reserve caller-owned cleanup for values you deliberately hand out.

The factory hands out a ticket; the closure is the usher who walks the aisle with you. Cleanup belongs to the usher, because they are still there when you get up and leave halfway through.

saying these in an interview costs you the question

  • Opens the file in the factory rather than inside the returned closure
  • Assumes the caller's break abandons the iterator and skips its defers
  • Defers Close inside the iterator's production loop, one per entry
  • Relies on the caller to run a cleanup function after the loop
  • Ignores yield's bool, so cleanup waits until the whole source is consumed
  • Expects the garbage collector to close the file eventually