supabase-rb-rb
Web mode

RefreshCoordinator

Supabase::Rails::Web::RefreshCoordinator — the per-worker mutex pool that serializes concurrent token refreshes keyed by SHA256(refresh_token), with refcounted entry cleanup.

Supabase::Rails::Web::RefreshCoordinator is the in-process mutex pool that prevents two concurrent threads in the same Puma worker from both calling auth.refresh_session for the same Supabase session. The pool is keyed by SHA256(refresh_token) — two requests carrying the same cookie cooperate on a single outbound call; two requests carrying different cookies run independently.

You almost never call this module directly — CookieCredentialStrategy wraps every refresh attempt in RefreshCoordinator.synchronize before deciding whether to actually issue the upstream call. The module is documented here because (a) the per-worker scope has visible behaviour during clustered deploys, (b) the refcounted cleanup is what keeps the hash from growing unboundedly in long-lived workers, and (c) the reset! / entry_count API exists for tests that need deterministic mutex state.

# Pseudocode for what CookieCredentialStrategy runs.
outcome = Supabase::Rails::Web::RefreshCoordinator.synchronize(refresh_token) do
  current = read_session_cookie
  if current && !near_expiry?(current)
    current # someone else already refreshed inside the mutex; reuse
  else
    auth_client.refresh_session(refresh_token)
  end
end

Why a mutex pool

The :web-mode cookie is the only credential the browser carries, and Supabase's refresh tokens rotate: a successful refresh_session returns a new refresh token and invalidates the old one. Two threads that hand the same old refresh token to Supabase at the same time will see one succeed and the other fail with a 4xx — even though both requests are perfectly legitimate. The losing thread would clear the cookie, signing the user out mid-page-load.

RefreshCoordinator.synchronize is the fix: while one thread holds the mutex, every other thread for the same refresh_token blocks. Inside the mutex, the strategy re-reads the cookie — by the time the second waiter wakes, the first has already written a fresh session, so the second reuses it instead of issuing a second refresh call. From the user's perspective, both requests are served with the new tokens; from Supabase's perspective, exactly one rotation happened.

Per-worker scope only

The mutex pool is a per-process Hash. On Puma with multiple workers, two simultaneous requests routed to different workers can both refresh — each worker has its own mutex. In practice browsers serialize cookie-bearing requests over HTTP/2 (and almost always over HTTP/1.1), so this race is observable only with hand-crafted concurrent clients. The PRD treats it as acceptable for v0.2 and revisitable if telemetry shows refresh contention.

Key derivation — SHA256(refresh_token)

Refresh tokens are kept out of memory beyond the request that carries them. The mutex pool keys by Digest::SHA256.hexdigest(refresh_token.to_s) instead of the raw token so:

  • A heap dump or crash log that captures @entries shows hex digests, not credentials.
  • Two RefreshCoordinator.synchronize(rt) calls in different parts of the codebase share a mutex even if one passes a String and the other passes a String.dup.
  • Coercing nil or an unexpected type via .to_s keys reliably ("" digests to a fixed hex string, which is fine — a nil refresh token shouldn't reach this code path, but defensive coercion avoids a NoMethodError).

The digest is a one-way mapping; there is no way to recover the refresh token from @entries. This matters during incident debugging — operators can safely log entry_count or even iterate keys to confirm the pool is doing what it should.

RefreshCoordinator.synchronize(refresh_token, &block)

ReturnsRaises
The block's return valueWhatever the block raises (the mutex is released on raise)

Yield the block while holding the per-token mutex. The pool entry is acquired before the block, released after — even if the block raises. The return value is the block's return value, unmodified.

result = Supabase::Rails::Web::RefreshCoordinator.synchronize(refresh_token) do
  # exclusive section — only one thread per refresh_token enters
  perform_refresh(refresh_token)
end

Reentrancy

Mutex#synchronize is not reentrant — calling RefreshCoordinator.synchronize(rt) from inside another RefreshCoordinator.synchronize(rt) for the same refresh token (and on the same thread) deadlocks the thread on itself. In practice, the only caller is CookieCredentialStrategy#refresh_or_clear, which never re-enters; this is documented for hosts writing custom middleware.

Exception safety

The begin/ensure wrapper guarantees checkin(key) runs even if the block raises. The refcount is decremented and the entry dropped when it hits zero, so a block that raises does not leak its mutex slot. The exception itself propagates to the caller (CookieCredentialStrategy's rescue StandardError clauses then classify it as :transient).

Refcounted entry cleanup

The pool is a Hash keyed by digest. Each entry is { mutex: Mutex.new, refs: Integer }. The refcount tracks concurrent holders so the entry can be dropped when no thread is holding it:

@entries = {
  "9f86d0..." => { mutex: #<Mutex>, refs: 2 }, # two threads currently refreshing this session
  "84983e..." => { mutex: #<Mutex>, refs: 1 }
}
StepWhat happens
checkout(key) (called before synchronize)Under @entries_mutex, find-or-create the entry, refs += 1, return the Mutex.
Block executesThe returned mutex is .synchronized.
checkin(key) (called in ensure)Under @entries_mutex, refs -= 1. If refs <= 0, @entries.delete(key).

The two-mutex design (@entries_mutex guarding the Hash, per-entry mutex guarding the critical section) keeps the entry-lookup path short — the Hash is only locked for the find-or-create-and-incref window, not for the duration of the refresh.

Without the refcount, the pool would grow with every unique refresh token ever observed by the worker — over weeks of uptime that's a memory leak. With it, the pool's size tracks the current concurrency: typically 0 in steady state, briefly 1 during a refresh, occasionally 2+ during a coordinated reload.

RefreshCoordinator.entry_count

ReturnsVisibility
Integer — current count of live entriesPublic, intended for tests and introspection.

Snapshot the number of live mutex entries in this worker. Mostly useful for tests that assert the pool was cleaned up after a refresh — a non-zero count after every test thread joined indicates a leaked checkin, which would mean a bug in the refcount accounting.

it "drops the entry when the refresh completes" do
  expect {
    described_class.synchronize("rt") { "ok" }
  }.not_to change(described_class, :entry_count)
end

The reading is under @entries_mutex so it sees a consistent count even with concurrent refreshes in flight.

RefreshCoordinator.reset!

ReturnsVisibility
nil (the internal Hash#clear result)Public but underscore-spelled reset! — test-only escape hatch.

Forcibly drop every entry. Intended for tests where a previous example may have leaked a synchronize block (e.g. via a forced thread kill) and the next example needs a clean pool. Calling this while another thread is inside synchronize is safe — the held mutex object still exists (Ruby's GC keeps it alive while the holding thread references it), but new checkout calls allocate a fresh entry.

RSpec.configure do |config|
  config.before(:each) { Supabase::Rails::Web::RefreshCoordinator.reset! }
end

There is no production use case for reset! — the refcounting handles steady-state cleanup. Calling it in a running app would not break anything (worst case, two concurrent refreshes for the same token would briefly race), but there is no reason to.

Worked example — two concurrent requests

Two browser tabs share the same cookie. Both fire GET /dashboard at the moment expires_at <= now + 10s.

Thread A                                 Thread B
   │                                        │
   │  CookieCredentialStrategy#call         │  CookieCredentialStrategy#call
   │  refresh_or_clear(session)             │  refresh_or_clear(session)
   │                                        │
   │  RefreshCoordinator.synchronize(rt)    │  RefreshCoordinator.synchronize(rt)
   │   checkout(key)  refs: 0 → 1           │   checkout(key)  refs: 1 → 2
   │   mutex.synchronize ─► acquired        │   mutex.synchronize ─► waiting...
   │     read cookie → stale                │
   │     auth.refresh_session(rt)           │
   │     ─► new Session                     │
   │     write_cookie(new_session)          │
   │     return new_session                 │
   │   mutex.synchronize ─► released        │   mutex.synchronize ─► acquired
   │   checkin(key)  refs: 2 → 1            │     read cookie → fresh enough!
   │                                        │     return existing session
   │                                        │   mutex.synchronize ─► released
   │                                        │   checkin(key)  refs: 1 → 0
   │                                        │   @entries.delete(key)
   │                                        │
   │  apply_outcome(new_session)            │  apply_outcome(existing_session)
   │  ─► user_context                       │  ─► user_context

Thread B's second cookie read is the load-bearing optimization. Without it, Thread B would wait for the mutex, then redundantly call auth.refresh_session(rt') with the new refresh token Thread A just wrote — at best a wasted network round-trip, at worst a 4xx if the upstream considers same-second double rotation an error. The re-read makes Thread B reuse Thread A's work.

What this module does not do

  • It does not own retry logic. A failed refresh raises; CookieCredentialStrategy decides whether to clear the cookie or surface REFRESH_UNAVAILABLE. The coordinator's job ends at "exactly one thread per token runs the block".
  • It does not span workers. Two requests routed to different Puma workers each acquire their own worker-local mutex and may both refresh. See the per-worker scope callout.
  • It does not enforce a timeout. Mutex#synchronize blocks indefinitely. The upstream Supabase::Auth::Client#refresh_session has its own connect/read timeouts (defaults from Net::HTTP), so a hung Supabase Auth backend bounds the wait without the coordinator needing its own timer.
  • It does not log. Logging happens in CookieCredentialStrategy — the coordinator is a pure synchronization primitive.

See also

  • Web mode overview — where the refresh phase fits into the request lifecycle.
  • CookieCredentialStrategy — the only production caller of synchronize, and the place where refresh outcomes are classified.
  • AuthClientFactory — builds the per-request Supabase::Auth::Client that refresh_session is called on inside the mutex.
  • Session store — the encrypted-cookie wrapper that the inside-mutex re-read calls.

On this page