Go 1.23.2 repairs garbage collection during unique handle lookup
The unique package could revive a weak reference without marking its object during collection. October's maintenance release fixes the runtime conversion that made rare crashes possible.
Creating a strong pointer can require work from the garbage collector even when no new object is allocated. Go's new unique package exposed that requirement through a rare crash. A weak reference could become a strong reference late in collection, and the collector could still reclaim the object. Go 1.23.2, released on October 1, includes the repair.
The defect is interesting because the public API deals in comparable values and handles. A caller does not manipulate weak pointers or coordinate with collection phases. Those are implementation mechanisms supporting the promise that a live handle retains its value. I like that separation in Go APIs, and it makes this an implementation defect that belongs below the application boundary.
Interning needs to avoid retaining every value forever
The Go 1.23 release notes describe unique as canonicalization for comparable values. Make returns a handle to a canonical copy. Handles compare equal exactly when the original values compare equal, and handle comparison reduces to a pointer comparison.
Canonicalization creates a lifetime problem. A table that keeps strong references to every canonical object would retain those objects through the table itself. The released unique implementation instead uses internal weak pointers in its type-specific maps. It can look up a canonical value without making the table's reference an ordinary permanent owner.
Make loads a weak pointer and attempts to obtain a strong pointer from it. If conversion succeeds, that strong pointer becomes the returned handle. If the weak pointer no longer names a live value, the code attempts to delete the dead entry and retries. Cleanup similarly inspects weak references and removes entries whose values are gone.
This gives collection a role in the lookup protocol. A weak pointer's validity is not established merely by finding an entry in the map. Conversion must coordinate with the collector before an application can safely retain the resulting handle.
A late conversion could escape marking
Issue 69210 reports a fatal pointer-to-free-object error while using unique.Handle heavily. The discussion initially asks whether application unsafe or cgo usage might explain it. The runtime investigation then identifies a weak-pointer scenario that can produce the observed failure without requiring that explanation.
An object first has a weak reference but no ordinary reference keeping it alive. A collection begins. Near the end of marking, the relevant goroutine stack has already been scanned. A weak-to-strong conversion creates a new valid pointer and places it only on that already-scanned stack.
Without another marking action, the collector can complete without marking the object. Its storage then becomes reclaimable despite the new strong reference. The investigator's explanation distinguishes creating a reference during collection from discovering references that were already present when a stack was scanned.
The issue records a reproducer and a fix in September, followed by a backport for Go 1.23. October's maintenance release supplies that correction to users of the stable toolchain. The report characterizes the failures as rare crashes with no workaround, rather than a deterministic failure of every handle lookup.
Conversion must mark the object
The fixed runtime conversion has several responsibilities. It prevents preemption while checking the weak handle, ensures the relevant span has been swept, and reloads the handle before treating the result as a strong pointer. Those steps address whether the stored reference still names valid storage.
The added marking step addresses the separate late-reference problem. While garbage collection is active, conversion shades the resulting pointer. The implementation comment explains that this preserves the relevant write-barrier invariant when the new pointer might exist only on a stack that has already been scanned.
The essential branch is:
if gcphase != _GCoff {
shade(uintptr(ptr))
}
This belongs in the conversion operation because that operation creates the strong reference. Marking only when inserting an entry into the canonicalization table would not cover a later conversion of an existing weak pointer. The collector needs to learn about the newly retained object at the point where its reachability changes.
The function also explicitly keeps the returned pointer alive across the call to shade. Its comments acknowledge that returning the pointer appears sufficient, but choose an explicit lifetime statement because this boundary is important. That is a narrow runtime concern, not an obligation exported to unique callers.
The public handle contract stays the same
The patch does not change value equality or require callers to retain a separate copy of the original value. A Handle[T] still contains the strong pointer returned by Make, and Value returns a shallow copy of its value. The repair makes the internal conversion uphold that existing lifetime contract.
I consider this a strong reason to read selected maintenance fixes beyond their package names. A library addition can depend on a runtime operation whose correctness requires coordination with collection phases. Here, the necessary change is small, but it restores the guarantee that a successful lookup produces storage the collector will keep alive.