fix(realtime): prevent concurrent presence writes on one socket from leaking a hold by ArnabChatterjee20k · Pull Request #14724 · appwrite/appwrite · GitHub
Skip to content

fix(realtime): prevent concurrent presence writes on one socket from leaking a hold - #14724

Open
ArnabChatterjee20k wants to merge 2 commits into
fix-realtime-presence-connections-counterfrom
fix-realtime-presence-concurrent-hold
Open

ArnabChatterjee20k wants to merge 2 commits into
fix-realtime-presence-connections-counterfrom
fix-realtime-presence-concurrent-hold

Conversation

@ArnabChatterjee20k

Copy link
Copy Markdown
Member

Stacked on #14606 (per-socket presence counter) — base branch fix-realtime-presence-connections-counter. Fixes a concurrency bug in that PR's handler; merge into it.

Issue

hansi flagged (on #14606): two overlapping presence messages on the same connection both read no stash before either writes it back, so both increment the row's connections count. onClose then releases the hold only once, leaving the socket online after it disconnects.

This is real because Runtime::enableCoroutine is on and Swoole does not serialize onMessage per connection — the DB write inside upsertForUserWithConnections yields, so a second presence message on the same socket interleaves before the first stashes its hold. Trigger: a client sending a second presence write (e.g. a status change) while the first is still in flight. A single presence write per connection is unaffected.

Solution

Stake a transient per-connection claim (presenceClaim) synchronously before the counting write yields. Swoole coroutines switch only on I/O, so a concurrent presence message on the socket observes the claim and refreshes instead of counting the socket twice.

  • Promoted to the real hold (presence) on success, so onClose releases exactly one count.
  • Dropped on failure so a retry can still count.
  • Preserved across a re-subscribe (added to the connection whitelist).
  • onClose and the close-race path are untouched — they only ever see a completed hold.

Testing

Handler-level coroutine logic, so it isn't a unit-testable State change. Verified by reasoning; recommend an e2e that sends two presence frames on one socket, closes it, and asserts the presence goes offline (no leak) — runnable once this is on staging.

🤖 Generated with Claude Code

…king a hold

Two overlapping presence messages on the same connection both read no hold
before either writes it back, so both increment the row's connections count
while onClose releases the hold only once -- the socket stays online after it
disconnects.

Stake a transient per-connection claim (presenceClaim) synchronously before the
counting write yields. Swoole coroutines switch only on I/O, so a concurrent
presence message on the socket observes the claim and refreshes instead of
counting the socket twice. The claim is promoted to the real hold on success,
dropped on failure so a retry can still count, and preserved across a
re-subscribe. onClose is untouched (it only ever sees a completed hold).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tenki-reviewer

tenki-reviewer Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

@hansi-codes

hansi-codes Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

🔵 Tier A · Mergeable after minor fixes

The normal overlapping-write path is serialized, but authentication can discard the lock during an in-flight write and let a second lock be created; the new concurrency behavior also has no regression coverage.

This pull request adds a per-connection presence lock so overlapping presence writes on one socket serialize, allowing later writes to refresh the existing hold rather than incrementing the connection count again. The lock is retained when the connection subscribes again, and the handler still reconciles a write that finishes after the socket closes.

Latest changes: The newest commits replace the earlier skip-ack claim with a channel-based mutex, so overlapping frames now run their full presence upsert in sequence; the lock is also added to the connection state retained by subscribe.

Verdict New comments Fixed Still open
💬 Commented 2 1 0

Note

Not approving while a bug finding is open: Authentication can drop the in-flight presence lock

Finding Where
🟡 Authentication can drop the in-flight presence lock src/Appwrite/Messaging/Adapter/Realtime.php:202
🟡 Add an overlapping-write regression test src/Appwrite/Realtime/Message/Handlers/Presence.php:82
Fix with agent prompt
### Issue 1
src/Appwrite/Messaging/Adapter/Realtime.php:202
**Authentication can drop the in-flight presence lock**

`Authentication::action()` calls `unsubscribe()` before resubscribing, so this whitelist cannot preserve the lock through that path; it only snapshots/restores `presence`. If authentication interleaves with a presence upsert, it can recreate the connection without this lock, letting another presence frame create a second mutex and count the same socket concurrently. Carry the lock through the authentication rebind as well.

### Issue 2
src/Appwrite/Realtime/Message/Handlers/Presence.php:82-88
**Add an overlapping-write regression test**

The existing realtime presence tests cover sequential frames, but none exercises two overlapping presence writes on one socket and then checks the close/offline outcome. That leaves the new mutex behavior—and the authentication rebind case—without regression coverage; an E2E test can use the real WebSocket path rather than a mocked boundary.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
📂 Walkthrough · 2
File Change
src/Appwrite/Messaging/Adapter/Realtime.php Documents the per-connection presence mutex and retains it during ordinary resubscriptions.
src/Appwrite/Realtime/Message/Handlers/Presence.php Serializes presence upserts per connection and keeps the existing close-race cleanup and hold refresh behavior.
✅ Fixed since the last review · 1
  • Overlapping writes are acknowledged without applying their data · src/Appwrite/Realtime/Message/Handlers/Presence.php:108

Reviewed the commits since 2559a96 · Details · Comment @hansi-codes review to re-run, or mention @hansi-codes with a question.

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · See the inline comments. Summary

Comment thread src/Appwrite/Realtime/Message/Handlers/Presence.php Outdated
@github-actions

Copy link
Copy Markdown

Security rules

No new WARNING or ERROR findings from security rules.

19 existing findings tracked in .semgrep/baseline.json
  • php.appwrite.guest-write-without-abuse-limit (12)
  • php.appwrite.secret-compare-timing (5)
  • php.appwrite.weak-secret-env-default (2)

Posted by Checks / Rules. Re-runs update this comment in place. Rule details and baseline: .semgrep/README.md.

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR adds a presenceClaim marker to the realtime presence handler so overlapping presence messages on the same socket refresh instead of double-counting; the fix works but the fast-path acknowledgement drops the second message's payload and can report success for a write that fails.

Key findings

  • 🟠 Claim stake resurrects closed connection, bypassing close guard — Presence.php:127
  • 🟡 Claim fast-path acks presence without persisting it — Presence.php:108
  • 🟡 Claim-ack response shape diverges from post-write path — Presence.php:108

Comment on lines +108 to +122
if (!$holdsUser && ($realtime->connections[$connectionId]['presenceClaim'] ?? null) === $userId) {
// A sibling presence write on this socket is already establishing the hold; acknowledge
// the submitted presence without a second counting write.
foreach (['$collection', '$tenant', 'hostname', 'permissionsHash', 'userInternalId', 'connections'] as $attribute) {
$presenceDocument->removeAttribute($attribute);
}

return [
'type' => 'response',
'data' => [
'to' => 'presence',
'presence' => $presenceDocument->getArrayCopy(),
],
];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Claim fast-path acks presence without persisting it

When a sibling presence write on the socket holds presenceClaim, the fast-path returns a success response built from the just-submitted document without writing it (src/Appwrite/Realtime/Message/Handlers/Presence.php:108-122), so the second message's status/metadata is silently dropped and no presences.[presenceId].upsert event fires for it.

The acknowledgement is also not tied to the sibling write's outcome: if that write throws (lines 131-139), the row is never written even though this message was already acknowledged as present. Clients sending rapid presence updates (heartbeat + explicit status change is the normal pattern) observe a success for state the server never recorded.

📋 Prompt for AI Agents

In src/Appwrite/Realtime/Message/Handlers/Presence.php lines 108-122, the presenceClaim fast-path returns a success response built from the submitted document without writing it, so the second overlapping presence message's status/metadata is never persisted and is acked even if the sibling write later throws (lines 131-139). Fix by deferring the response until the sibling's upsertForUserWithConnections resolves: queue the dropped payload (e.g. re-dispatch the message once the hold is stashed, or persist the second message's status/metadata with a non-counting refresh write against the held sequence), and surface the sibling's error instead of a success when the write fails.

Comment on lines +108 to +122
if (!$holdsUser && ($realtime->connections[$connectionId]['presenceClaim'] ?? null) === $userId) {
// A sibling presence write on this socket is already establishing the hold; acknowledge
// the submitted presence without a second counting write.
foreach (['$collection', '$tenant', 'hostname', 'permissionsHash', 'userInternalId', 'connections'] as $attribute) {
$presenceDocument->removeAttribute($attribute);
}

return [
'type' => 'response',
'data' => [
'to' => 'presence',
'presence' => $presenceDocument->getArrayCopy(),
],
];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Claim-ack response shape diverges from post-write path

The claim fast-path returns the client-built $presenceDocument, which lacks the server-assigned attributes the post-write path returns from the database: $id (when presenceId is unique()), $createdAt, $updatedAt, and $sequence (src/Appwrite/Realtime/Message/Handlers/Presence.php:115-121).

A client whose message lands while a sibling presence write on the same socket is in flight receives an acknowledged presence with a structurally different payload than one received a moment later — same message type, different shape, on the same SDK surface.

📋 Prompt for AI Agents

In src/Appwrite/Realtime/Message/Handlers/Presence.php lines 108-122, the claim-ack early return responds with the raw client-built $presenceDocument, missing the server-assigned $id, $createdAt, $updatedAt and $sequence that the post-write path returns from the database. Make the acknowledged shape match the persisted response — e.g. defer the response until the sibling write resolves and echo its persisted document — keeping the existing attribute-stripping list unchanged.

Comment on lines +127 to +129
if (!$holdsUser) {
$realtime->connections[$connectionId]['presenceClaim'] = $userId;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 bug · high

Claim stake resurrects closed connection, bypassing close guard

The claim stake $realtime->connections[$connectionId]['presenceClaim'] = $userId auto-vivifies a new entry if the socket closed and unsubscribe() already removed connections[$connectionId] during the getDocument I/O yield at line 74 (src/Appwrite/Realtime/Message/Handlers/Presence.php:127-129).

The mid-write-close guard !isset($realtime->connections[$connectionId]) at line 146 then observes the resurrected zombie entry and skips the releaseConnectionForUser undo path, so the counted presence row is stashed at line 174 onto an entry with no live socket and is never released. The user stays visible online until the row's 30-day expiresAt.

📋 Prompt for AI Agents

In src/Appwrite/Realtime/Message/Handlers/Presence.php at lines 126-129, wrap the claim stake in an existence check: if (!$holdsUser && isset($realtime->connections[$connectionId])) { $realtime->connections[$connectionId]['presenceClaim'] = $userId; }. Without this, a socket that closes during the getDocument I/O yield at line 74 has its connection entry removed by onClose/unsubscribe(), and the stake auto-vivifies a zombie entry that defeats the !isset(...) mid-write-close guard at line 146 — the counted presence row is then never released and the user leaks as online until the 30-day expiresAt.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

✨ Benchmark results

Comparing fix-realtime-presence-connections-counter (before) → fix-realtime-presence-concurrent-hold (after).

Metric Before After Change
🚀 Requests/sec 214.22 223.49 ⚪ +4.3%
⏱️ Latency P50 79.78 ms 79.25 ms ⚪ -0.7%
⏱️ Latency P95 193.35 ms 173.9 ms 🟢 -10.1%
Per-scenario breakdown & investigation details

Metrics below reflect the current branch (after). Δ P95 compares against the base.

Scenario P50 (ms) P95 (ms) Requests RPS Δ P95 (ms)
API total 79.25 173.9 13,737 223.49 -19.45
Account 137.98 253.93 723 12.27 -46.76
TablesDB 78.53 147.32 7,471 123.03 -8.78
Storage 69.49 159.83 3,615 61.55 -3.82
Functions 106 195.11 1,928 33.56 -33.03

Top API waits (after)

API request Max wait (ms)
account.name.update 441.11
functions.create 398.22
account.prefs.update 357.26
functions.variables.update 351.87
functions.delete 338.85

…cking

Addresses review of the claim fast-path: skipping the second overlapping write
dropped its status/metadata, fired no upsert event, returned a client-built
document with no server attributes, and could falsely ack when the first write
failed; staking the claim also auto-vivified a connection onClose had already
removed, so the mid-write-close guard saw a zombie entry and leaked the row.

Replace the claim with a per-connection Swoole\Coroutine\Channel mutex taken
before the first I/O yield (while the connection is known to exist, so it never
resurrects a closed entry). Overlapping presence messages on one socket now run
one at a time: the second reads the first's stashed hold and takes the normal
refresh path -- persisting its data, emitting its event, and returning the real
database document -- instead of counting the socket twice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@hansi-codes hansi-codes Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Tier A · See the inline comments. Summary

// Recorded once by the connection handler or a presence write; every later (re)subscribe
// keeps it, so a `subscribe` message after a presence message never drops the hold stash.
foreach (['authorization', 'sessionId', 'impersonatedUserId', 'impersonator', 'expire', 'jwtExpire', 'presence'] as $key) {
foreach (['authorization', 'sessionId', 'impersonatedUserId', 'impersonator', 'expire', 'jwtExpire', 'presence', 'presenceLock'] as $key) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Authentication can drop the in-flight presence lock

Authentication::action() calls unsubscribe() before resubscribing, so this whitelist cannot preserve the lock through that path; it only snapshots/restores presence. If authentication interleaves with a presence upsert, it can recreate the connection without this lock, letting another presence frame create a second mutex and count the same socket concurrently. Carry the lock through the authentication rebind as well.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Appwrite/Messaging/Adapter/Realtime.php
Line: 202

Comment:
**Authentication can drop the in-flight presence lock**

`Authentication::action()` calls `unsubscribe()` before resubscribing, so this whitelist cannot preserve the lock through that path; it only snapshots/restores `presence`. If authentication interleaves with a presence upsert, it can recreate the connection without this lock, letting another presence frame create a second mutex and count the same socket concurrently. Carry the lock through the authentication rebind as well.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟡 Minor · concurrency · Reply if this doesn't apply.

Comment on lines +82 to +88

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add an overlapping-write regression test

The existing realtime presence tests cover sequential frames, but none exercises two overlapping presence writes on one socket and then checks the close/offline outcome. That leaves the new mutex behavior—and the authentication rebind case—without regression coverage; an E2E test can use the real WebSocket path rather than a mocked boundary.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/Appwrite/Realtime/Message/Handlers/Presence.php
Line: 82-88

Comment:
**Add an overlapping-write regression test**

The existing realtime presence tests cover sequential frames, but none exercises two overlapping presence writes on one socket and then checks the close/offline outcome. That leaves the new mutex behavior—and the authentication rebind case—without regression coverage; an E2E test can use the real WebSocket path rather than a mocked boundary.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟡 Minor · testing · Reply if this doesn't apply.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant