Problem
Backend::getAndSet() has a Time-of-Check-Time-of-Use (TOCTOU) race condition. It performs a non-atomic get() then set(), so concurrent callers that both observe a miss will each compute and write different values. The last writer wins, silently orphaning data written by earlier callers under the first value.
|
public function getAndSet($key, callable $callback, int $expiration = 0, |
|
$reset = false) { |
|
$value = $reset ? self::MISS : $this->get($key); |
|
|
|
if ($value === self::MISS) { |
|
$value = $callback(); |
|
|
|
if ($value !== self::MISS) { |
|
$this->set($key, $value, $expiration); |
|
} |
|
} |
|
|
|
return $value; |
|
} |
This is benign for single-process backends (Ephemeral) but is a real bug for shared-memory backends like APCu, where multiple PHP-FPM workers share the same cache.
Slack thread with more details, and another one leading to the creation of this issue
Impact on Scope
Scope::getScopePrefix() uses getAndSet to lazily initialize a random scope prefix:
|
public function getScopePrefix(bool $reset = false) { |
|
if ($this->scopePrefix === null || $reset) { |
|
$scopeValue = $this->backend->getAndSet($this->getScopeKey(), |
|
function() { |
|
return substr(md5(microtime() . $this->scopeName), 0, 16); |
|
}, 0, $reset); |
|
|
|
$this->scopePrefix = "{$scopeValue}-"; |
|
} |
|
|
|
return $this->scopePrefix; |
|
} |
The race:
Worker A: get("scope-foo") → MISS
Worker B: get("scope-foo") → MISS
Worker A: callback() → "aaa...", set("scope-foo", "aaa...")
Worker A: set("aaa...-mykey", data)
Worker B: callback() → "bbb...", set("scope-foo", "bbb...") ← overwrites A's prefix
Worker A: get("scope-foo") → "bbb..." ← prefix changed
Worker A: get("bbb...-mykey") → MISS ← data orphaned under "aaa..."
Any worker that wrote cache entries between A's set and B's set has orphaned those entries. The scope prefix is also cached in $this->scopePrefix (an instance variable), so within a single request the stale prefix persists even after it's been overwritten in the backend — causing all subsequent reads to miss for the rest of that request.
Options
1. Use add() instead of set() in getAndSet
First writer wins. After add(), re-get() to learn the winning value.
public function getAndSet($key, callable $callback, int $expiration = 0, $reset = false) {
$value = $reset ? self::MISS : $this->get($key);
if ($value === self::MISS) {
$value = $callback();
if ($value !== self::MISS) {
if (!$this->add($key, $value, $expiration)) {
$value = $this->get($key);
}
}
}
return $value;
}
Pro: Simple, uses an existing primitive, correct for the scope-initialization use case.
Con: Changes semantics — today getAndSet with $reset=false still overwrites on concurrent miss. Callers relying on last-writer-wins (if any exist) would break. The $reset=true path still needs set() (intentional overwrite), so the two paths would diverge.
2. Add a separate getOrAdd method
Keep getAndSet as-is, add a new method that uses add() for atomic initialization. Update Scope to call getOrAdd.
Pro: No behavior change to existing API. Explicit opt-in.
Con: More API surface.
3. Fix only in Scope
Have Scope::getScopePrefix() call add() + get() directly instead of going through getAndSet.
Pro: Minimal change, fixes the concrete bug.
Con: Doesn't fix the general footgun — other callers of getAndSet on shared backends have the same problem.
Ambiguities
- Is
getAndSet intended to be atomic? The docstring doesn't say, but the name and usage pattern (lazy cache population) strongly implies it. If it's explicitly non-atomic, that should be documented as a caveat for shared backends.
$reset=true semantics with add(): Option 1 would need to keep using set() for the reset path (intentional overwrite). This is correct but means getAndSet uses two different write primitives depending on $reset.
Scope::$scopePrefix instance caching: Even after fixing the backend race, the instance variable $this->scopePrefix means a Scope object caches the prefix for its lifetime. If another worker calls deleteScope(), this instance won't see the new prefix until getScopePrefix($reset=true) is called. This may be intentional (scope invalidation is eventually consistent within a request) but is worth clarifying.
CC @sctice-ifixit
Problem
Backend::getAndSet()has a Time-of-Check-Time-of-Use (TOCTOU) race condition. It performs a non-atomicget()thenset(), so concurrent callers that both observe a miss will each compute and write different values. The last writer wins, silently orphaning data written by earlier callers under the first value.Matryoshka/library/iFixit/Matryoshka/Backend.php
Lines 132 to 145 in ab81f01
This is benign for single-process backends (Ephemeral) but is a real bug for shared-memory backends like APCu, where multiple PHP-FPM workers share the same cache.
Slack thread with more details, and another one leading to the creation of this issue
Impact on Scope
Scope::getScopePrefix()usesgetAndSetto lazily initialize a random scope prefix:Matryoshka/library/iFixit/Matryoshka/Scope.php
Lines 24 to 35 in ab81f01
The race:
Any worker that wrote cache entries between A's
setand B'ssethas orphaned those entries. The scope prefix is also cached in$this->scopePrefix(an instance variable), so within a single request the stale prefix persists even after it's been overwritten in the backend — causing all subsequent reads to miss for the rest of that request.Options
1. Use
add()instead ofset()ingetAndSetFirst writer wins. After
add(), re-get()to learn the winning value.Pro: Simple, uses an existing primitive, correct for the scope-initialization use case.
Con: Changes semantics — today
getAndSetwith$reset=falsestill overwrites on concurrent miss. Callers relying on last-writer-wins (if any exist) would break. The$reset=truepath still needsset()(intentional overwrite), so the two paths would diverge.2. Add a separate
getOrAddmethodKeep
getAndSetas-is, add a new method that usesadd()for atomic initialization. UpdateScopeto callgetOrAdd.Pro: No behavior change to existing API. Explicit opt-in.
Con: More API surface.
3. Fix only in
ScopeHave
Scope::getScopePrefix()calladd()+get()directly instead of going throughgetAndSet.Pro: Minimal change, fixes the concrete bug.
Con: Doesn't fix the general footgun — other callers of
getAndSeton shared backends have the same problem.Ambiguities
getAndSetintended to be atomic? The docstring doesn't say, but the name and usage pattern (lazy cache population) strongly implies it. If it's explicitly non-atomic, that should be documented as a caveat for shared backends.$reset=truesemantics withadd(): Option 1 would need to keep usingset()for the reset path (intentional overwrite). This is correct but meansgetAndSetuses two different write primitives depending on$reset.Scope::$scopePrefixinstance caching: Even after fixing the backend race, the instance variable$this->scopePrefixmeans a Scope object caches the prefix for its lifetime. If another worker callsdeleteScope(), this instance won't see the new prefix untilgetScopePrefix($reset=true)is called. This may be intentional (scope invalidation is eventually consistent within a request) but is worth clarifying.CC @sctice-ifixit