Found while sweeping the class of problem behind #693 and #689: a static cache keyed by something a game varies at runtime, with no bound and no eviction anybody calls.
The shape
Runtime/Core/DataStructure/StringWrapper.cs:
#if SINGLE_THREADED
private static readonly Dictionary<string, StringWrapper> Cache = new();
#else
private static readonly ConcurrentDictionary<string, StringWrapper> Cache = new();
#endif
public static StringWrapper Get(string value)
{
...
return Cache.GetOrAdd(value, static key => new StringWrapper(key));
}
Every distinct string ever passed to Get keeps a StringWrapper and the string itself alive
for the life of the process. A caller that wraps a value derived from gameplay — an entity id, a
save slot name, a network message key, a $"{prefix}:{index}" — grows the cache without bound.
Why it is not simply the #689 defect again
#689 bounded SetBuffers/DictionaryBuffer's comparer caches because interning a comparer was
never the point: the cache was an optimisation and losing an entry costs one pool construction.
Here interning is the type's stated purpose. Its own remarks say so:
Wrappers are shared. Every caller asking for the same string gets the same instance, so
dropping one is an administrative act on behalf of all of them.
and it ships Remove(string) and Clear() precisely so a caller can do that administration. So
the honest question is not "is this a leak" but "is unbounded the right default for a public type
whose only eviction is a call nobody makes?"
Note Equals compares by ordinal value and GetHashCode comes from the string, so a wrapper handed
out after an eviction is still equal to one handed out before it. Nothing in the type's own contract
depends on reference identity — which is what makes bounding it viable at all.
What would close this
Not this issue
The comparer-keyed pool caches (#689, bounded) and Helpers.Find(tag)'s cache (fixed alongside
#643 — an unloaded scene now drops the entries whose objects went with it).
Found while sweeping the class of problem behind #693 and #689: a static cache keyed by something a game varies at runtime, with no bound and no eviction anybody calls.
The shape
Runtime/Core/DataStructure/StringWrapper.cs:Every distinct string ever passed to
Getkeeps aStringWrapperand the string itself alivefor the life of the process. A caller that wraps a value derived from gameplay — an entity id, a
save slot name, a network message key, a
$"{prefix}:{index}"— grows the cache without bound.Why it is not simply the #689 defect again
#689boundedSetBuffers/DictionaryBuffer's comparer caches because interning a comparer wasnever the point: the cache was an optimisation and losing an entry costs one pool construction.
Here interning is the type's stated purpose. Its own remarks say so:
and it ships
Remove(string)andClear()precisely so a caller can do that administration. Sothe honest question is not "is this a leak" but "is unbounded the right default for a public type
whose only eviction is a call nobody makes?"
Note
Equalscompares by ordinal value andGetHashCodecomes from the string, so a wrapper handedout after an eviction is still equal to one handed out before it. Nothing in the type's own contract
depends on reference identity — which is what makes bounding it viable at all.
What would close this
git grep 'StringWrapper.Get'acrossRuntime/,Editor/andTests/,and decide whether any first-party caller can feed it an unbounded key set. If none can, this
is a documentation task, not a code one.
Buffers.ComparerPoolMaxDistinctEntriesshape,already in the package twice — the wait-instruction caches and the comparer caches); a
Get-with-no-cache overload for callers who know their key is one-shot; or an explicitrecorded decision that unbounded is correct for an interning type and the remarks should say
"this never releases" in those words.
StringWrapper's remarks should state the retention policy where aconsumer reads it, because today they explain the sharing and say nothing about the
lifetime.
Not this issue
The comparer-keyed pool caches (#689, bounded) and
Helpers.Find(tag)'s cache (fixed alongside#643 — an unloaded scene now drops the entries whose objects went with it).