Skip to content

TOCTOU race in getAndSet causes data loss with shared backends #42

Description

@djmetzle

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions