iFixit / iFixit/Matryoshka

TOCTOU race in getAndSet causes data loss with shared backends

Open
#42 10 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
PHP
Stars
23
Forks
3
PR merge metrics
No merged PRs in 30d

Description

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.

https://github.com/iFixit/Matryoshka/blob/ab81f01015b16eeeaf2d380e5bb680fb8ec2974c/library/iFixit/Matryoshka/Backend.php#L132-L145

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:

https://github.com/iFixit/Matryoshka/blob/ab81f01015b16eeeaf2d380e5bb680fb8ec2974c/library/iFixit/Matryoshka/Scope.php#L24-L35

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

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with library/iFixit/Matryoshka/Backend.php around getAndSet(), then inspect library/iFixit/Matryoshka/Scope.php and callers using shared backends. Determine whether atomic initialization belongs in getAndSet(), a new getOrAdd method, or Scope alone, while preserving the intended reset behavior. Done means concurrent initialization no longer loses or orphans scope data and the chosen API semantics are documented.

Written by the indexing model from the issue text.

Assessment

Tech stack
php
Domain
backend
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.