# Exercise 01 — The browser that never gives memory back

**Failure mode:** strong reference cycle — an object graph ARC cannot collect
**Language:** Swift 6 · **Runtime:** about 6 seconds · **Difficulty:** the one everybody thinks they already know

---

## The prompt

> A thumbnail browser decodes 4,000 thumbnails per folder. Each `Thumbnailer` stores a
> completion handler on itself so a failed decode can be retried. When the user navigates
> away, the cache is emptied.
>
> QA reports: "memory climbs while browsing folders and never comes back down."
>
> The engineer who wrote it points out, correctly, that the file compiles with **zero
> warnings under Swift 6 strict concurrency**, that there is no `unowned`, no manual
> `retain`, no C, and no unsafe pointer anywhere in it.
>
> Prove the defect exists. Fix it. Then explain why the fix does **not** reduce the
> process's memory footprint on the first folder — and what measurement does show it.

Answer before you run anything: **what is the difference between a leak and high memory
use, and which one is being reported here?**

---

## What you are given

| Path | What it is |
| --- | --- |
| `broken/thumbcache.swift` | The starting point. A stored closure that captures `self`. |
| `fixed/thumbcache.swift` | One correct repair. |
| `solution.patch` | The diff between the two, applies with `patch -p1`. |
| `check.sh` | Proves the growth in one build and the plateau in the other. |

---

## Reproduce the measurement

```bash
cd exercises/01-retain-cycle
swiftc -swift-version 6 -O broken/thumbcache.swift -o /tmp/thumb_broken
swiftc -swift-version 6 -O fixed/thumbcache.swift  -o /tmp/thumb_fixed
/tmp/thumb_broken
/tmp/thumb_fixed
```

Observed on an Apple M4 Pro (10P + 4E), macOS 26.3 (25D125), Swift 6.2.4:

```
broken                                   fixed
round=1 liveObjects=4000  footprintKB=322848    round=1 liveObjects=0 footprintKB=323200
round=2 liveObjects=8000  footprintKB=644560    round=2 liveObjects=0 footprintKB=390688
round=4 liveObjects=16000 footprintKB=1287937   round=4 liveObjects=0 footprintKB=423488
round=8 liveObjects=32000 footprintKB=2574594   round=8 liveObjects=0 footprintKB=423488
deinits=0     live=32000  growthKB=2251745      deinits=32000 live=0  growthKB=100288
```

**Reproduce these; do not quote them.** They are one machine under one load.

---

## The evidence to collect

Four observations, in this order. The order matters more than any one of them.

1. **`deinits=0`.** Not one `Thumbnailer` was ever released. This is the *direct* evidence,
   and it needs no profiler: a `deinit` that never runs is a fact, not a statistic.

2. **`footprintKB` after round 1 is the same in both builds** — about 322 MB either way.
   This is the trap. `free()` returns memory to the **allocator**, not to the operating
   system, so a single round cannot distinguish a leak from healthy reuse. An engineer who
   measures one round and sees no difference will conclude the fix did nothing.

3. **Growth across rounds is the discriminator.** Broken grows by exactly one folder's worth
   every round, forever. Fixed rises for three rounds and then stops dead. The plateau is
   the allocator settling at its high-water mark, which is why `growthKB` for the fixed
   build is hundreds of megabytes rather than 0 — **assert bounded, not zero.** That
   high-water mark is also not repeatable to the megabyte, so the check asserts a loose
   ceiling on it and puts the real weight on the broken/fixed *ratio*.

4. **`leaks(1)` names it for you.** Run the broken build under stack logging and ask:

   ```bash
   MallocStackLogging=lite /tmp/thumb_broken &
   leaks $!            # prints ROOT LEAK entries with the allocating stack
   ```

   On a Swift object graph the cycle also shows up in Instruments' **Leaks** instrument,
   and a pair of **heap generations** taken in Allocations before and after one folder
   shows the growth that survives the round.

---

## Success criteria

- [ ] The broken build still leaks — you have not edited it.
- [ ] Your fixed build reports `deinits == created` and `live == 0`.
- [ ] Your fixed build's `checksum` and `records` equal the broken build's, exactly. A
      lifetime fix that changes the answer is a different program.
- [ ] Your fixed build's `growthKB` stays under the loose ceiling of **two rounds' worth of
      pixels** — not zero, and you can say why zero would be the wrong assertion.
- [ ] You can say why that ceiling is loose and why the **broken/fixed growth ratio** is the
      assertion that actually decides. The fixed build's absolute growth is a high-water mark
      that moves with load: 12 runs on the reference machine spanned 162,288–326,112 KB, or
      0.63×–1.27× of one round. The broken build's was 8.80× of one round in every run.
- [ ] You can state which reference in the cycle you broke, and why breaking *that* one is
      correct rather than merely effective.

Run `./check.sh` to have all of that checked for you.

---

## Hints

<details>
<summary>Hint 1 — draw the arrows</summary>

Three objects are in play: the array, the `Thumbnailer`, and the closure. Draw an arrow for
every strong reference. The array's arrow disappears at `removeAll`. Two arrows remain, and
they point at each other.
</details>

<details>
<summary>Hint 2 — ARC is not a garbage collector</summary>

ARC releases an object when its strong reference count reaches zero. It does not trace, it
does not look for unreachable islands, and it will never notice that these two objects are
unreachable from your program. That is not a bug in ARC; tracing is exactly the cost ARC
exists to avoid.
</details>

<details>
<summary>Hint 3 — which edge should be weak, and why that one</summary>

The rule is ownership, not convenience: the **owner** holds strongly, the **owned** refers
back weakly. Ask which of the two objects can meaningfully outlive the other. A completion
handler for a `Thumbnailer` that no longer exists has nothing to complete — so the handler
does not own the `Thumbnailer`, and the reference from handler to object is the one to
weaken.

`unowned` would also break the cycle. Ask yourself what happens if it is wrong.
</details>

---

## Solution

<details>
<summary>Reveal the solution</summary>

### The repair

```swift
onComplete = { [weak self] in
    guard let self else { return }
    self.counters.checksum &+= UInt64(self.pixels[0]) &+ UInt64(self.index)
    self.counters.records += 1
}
```

`[weak self]` makes the closure's reference to the object non-owning, so the cycle
`Thumbnailer → onComplete → Thumbnailer` is no longer a cycle. When the array drops its
last strong reference the count reaches zero and `deinit` runs.

### Why `weak` and not `unowned`

Both break the cycle. `unowned` is a promise that the referent outlives the reference; if
that promise is broken the access traps. Here the referent is precisely the object whose
death we are arranging, and the handler can in principle be invoked from a retry path after
release. A `guard let self else { return }` turns "the object is gone" into a no-op, which
is the correct semantics for a retry. Reach for `unowned` only when you can state the
lifetime argument out loud.

### Why the footprint did not drop on round one

Freeing an object returns its bytes to the **allocator's** free lists. The allocator hands
them out again on the next request; it does not, in general, hand the pages back to the
kernel. So round one costs the same either way and only the *slope* differs. This is not a
Swift property and not an ARC property — it is how `malloc` behaves, and exercise 02 makes
the same point from the other direction.

The practical consequence for diagnosis: **measure a repeated operation, not a single one.**
One navigate-away proves nothing. Eight in a row proves everything.

### What the fix costs

One optional unwrap per invocation, and a weak reference, which on Apple platforms means
the object gets a side-table entry the first time it is weakly referenced. Both are
negligible here. The real cost is the same as exercise 01 of any lifetime bug: somebody has
to keep noticing. Every closure stored **on** the object it captures is a review trigger.
</details>

---

## Going further

- Add a third round-trip: store the closure somewhere else entirely (a global registry) and
  observe that the cycle disappears while the *leak* does not. Distinguish "cycle" from
  "unbounded retention" in your answer; interviewers ask for both and accept either only
  when you name which one you mean.
- Turn on `MallocStackLogging=lite` and compare `malloc_size()` of the same request with and
  without it. The instrumented allocator uses different size classes, so a heap measurement
  taken under stack logging does not have production's rounding.
