defer stays the answer; drop is deferred and the shim can count what leaks

This commit is contained in:
Joseph Ferano 2026-09-12 14:31:15 +07:00
parent a3fccf440c
commit a1827adb66

43
NEXT.md
View File

@ -362,6 +362,49 @@ Already banked, and it means classes are less work than plan.org implies: **a ge
whose body is a dispatch table, which a reload extends. That is the expensive half of method dispatch, and it is built
and tested.
**Resource cleanup: `defer` stays the answer. `drop` is not built, and `with-cleanup` is not either.** Reached by
working the case through rather than by preference, so the reasoning is worth keeping.
`drop` was specified in `spec-memory.md` this morning. **This amends it: the hook is deferred, not built.** Three things
decided against it. It runs code somewhere the reader is not looking, which is the C++ behaviour the author explicitly
does not want. It would not even cover the motivating case — `Image` and `Texture2D` are *raylib's* types, and attaching
a hook to a foreign type is its own unsolved design question. And its one real advantage, cascading through a container,
is the case `Handle` is about to make rare: entities holding handles hold numbers, not resources.
`with-cleanup` / `unwind-protect` was also put and rejected: awkward with several resources, and it reads worse than
what already exists. The raylib begin/end pairs that seemed to motivate it are a macro problem, not a primitive one —
`with-drawing` and `with-mode-2d` are three-line macros once the expander lands.
**What to build instead is small: relax where `defer` may be written.** It is refused today inside a `let`, a loop or a
branch. The loop and branch refusals are right — `defer` is a *compile-time* construct, the cleanup copied into every
exit path, so "maybe registered" is not expressible and a loop body would fire once at function exit instead of once per
iteration. But **a `let` at the top level of a function body has exactly the function's extent** and always registers,
so it is as safe as function scope and is refused for a reason that does not apply to it. Relaxing it gives:
```
(defn load-brush []
(let [sheet (rl/load-image-from-memory ".png" brush-bytes)]
(defer (rl/unload-image sheet))
(set brush (rl/load-texture-from-image sheet))
(rl/image-flip-horizontal (addr sheet))
(set brush-mirrored (rl/load-texture-from-image sheet))))
```
Several resources are several defers, released in reverse, visible in acquisition order. A container of resources is an
ordinary loop inside the defer body — the manual cascade, three lines, at one level of nesting.
**Odin, for the record**, has no destructors, no drop and no finalizers: `delete` frees container memory and nothing
else, and resource release is `defer` at the acquisition site. That idiom does not transfer directly only because
Odin's `defer` is block-scoped; the relaxation above recovers most of it.
**The safety net, and the better use of effort: a debug tracking allocator.** ASan's leak detection covers memory
*instrumented* code allocated — the Flan allocator, and it is already wired up and clean. It does **not** cover a leaked
texture, because that memory belongs to uninstrumented raylib, which is the same reason the sanitizer sweep treats the
windowed examples as noise. But every raylib call goes through a *generated* wrapper, so a dev build can count
acquisitions against releases at that boundary and report what is still held at exit, by name. No hook, no type
annotation, nothing running at a distance — it does not change how code is written, it reports when something was
forgotten.
## Blocked and unfinished
Everything below was found, decided or half-built and then stopped. Each says what blocks it. Nothing here is a