Skip to content

fix(server-presence): clear session active on explicit stop disconnect - #223

Open
danljungstrom wants to merge 4 commits into
happier-dev:devfrom
danljungstrom:fix/clear-active-on-explicit-stop
Open

fix(server-presence): clear session active on explicit stop disconnect#223
danljungstrom wants to merge 4 commits into
happier-dev:devfrom
danljungstrom:fix/clear-active-on-explicit-stop

Conversation

@danljungstrom

@danljungstrom danljungstrom commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Problem

Archiving a session from the UI fails, and the session "goes inactive on its own" ~10 minutes later.

POST /v2/sessions/:id/archive is gated on active === false (registerSessionArchiveRoutes.ts:56). An accepted stop kills the runner, but the runner dies by hard disconnect, and forgetDisconnectedPublisher deliberately only records observer loss — it leaves active set. The only thing that clears it is the presence sweep, on a 10-minute fence (timeout.ts:18).

The client retries archive for 75 seconds (sessionStopArchiveFlow.ts, 200ms apart). 75s ≪ 600s, so the client always gives up first and archive-after-stop cannot complete from the UI.

Observed on a self-hosted dev server: 3,899 × HTTP 409 session-active in 24h, in bursts of 330–670 per session — one burst per attempt, each burst a retry loop that expires. Sessions then flip to inactive minutes later when the sweep fires.

Machine-scoped stop already finalized correctly via captureExplicitMachineStopfinalizeExplicitMachineStop; the session-scoped <sessionId>:killSession path had no server-side lifecycle handling at all. That asymmetry is the bug.

Change

  • Record explicit-stop intent only when a runner accepts the stop (stopped/requested, or a legacy {success:true} ack). A transport error, refusal, or RPC_METHOD_NOT_AVAILABLE records nothing.
  • Persist that intent on Session.stopRequestedAt rather than in process memory. With redisRegistry.enabled the accepting RPC and the runner's publisher socket can be served by different instances, so a process-local intent never reaches the disconnect that resolves it.
  • On publisher disconnect, a live intent routes through the existing closeBindingAtFence instead of only recording observer loss, so active clears in seconds.
  • Socket layer publishes active: false on that close so clients learn immediately rather than at fence expiry.
  • Run the stop lifecycle on every successful forward. All three forward sites previously invoked forwardTargetResponse only inside if (callback), so an RPC emitted without an acknowledgement was forwarded and accepted while the server recorded nothing — and skipped the machine-scoped stop finalize, which predates this PR.

forgetDisconnectedPublisher reads and clears the intent inside the transaction it already opens, so the consume and the close commit together. closeBindingAtFence is split into an in-transaction owner plus a thin inTx wrapper, letting the disconnect path reuse it without nesting a second transaction. The intent write is awaited in rpcHandler because it is durable now — the disconnect that reads it can arrive as soon as the runner begins tearing down.

Safety properties:

  • A superseded fence falls through to today's behaviour — a successor publisher is never closed.
  • A failed stop records no intent, so an incidental disconnect cannot end a session whose runner is still alive.
  • A registering publisher clears any intent left by a predecessor, so a successor's own later disconnect cannot be read as that stop.
  • Intent expires after 2 minutes, compared against the persisted timestamp, and is cleared by the disconnect that consumes it.

Migration

One new nullable column, Session.stopRequestedAt, with a migration per provider (prisma/migrations, prisma/mysql/migrations, prisma/sqlite/migrations). Additive and backward-compatible: no backfill, no default, and a server running older code ignores the column.

Tests

  • sessionPublisherPresence.sqlite.integration.spec.ts
    • Disconnect after an accepted stop closes the exact publisher with full participant fanout. Verified RED (expected 'applied' to be 'closed') with the behaviour reverted.
    • Intent marked on one presence instance is consumed by a second instance sharing the database. Verified RED (expected 'applied' to be 'closed') against the in-memory map.
    • A successor publisher's disconnect does not consume its predecessor's intent. Verified RED (expected 'closed' not to be 'closed') before registration cleared the intent.
  • rpcHandler.integration.spec.ts
    • A session stop RPC with no reachable runner records no intent. Verified by mutation: reintroducing eager marking makes it fail (expected "spy" to not be called at all, but actually been called 1 times).
    • An accepted stop sent without an acknowledgement callback still records intent. Verified RED (forward observed, markExplicitStopRequested calls: 0). Worth noting for reviewers: callback?.(await forwardTargetResponse(response)) does not fix this — optional-call short-circuiting skips argument evaluation, so the test stayed red until the result was bound to a local first.
Check Result
Presence integration suite 23/23
rpcHandler integration suites 22/22
Socket unit suites 92/92
Server unit lane (yarn test) 1349/1349
Server integration lane (yarn test:integration) 846 passed, 11 pre-existing failures (see below)
Server typecheck (yarn build) pass
test:migration:inventory pass

Not verified

No end-to-end run against a live server, and no multi-process run with Redis actually enabled — the cross-instance behaviour is proven at the storage boundary by two presence instances sharing one database, not by two server processes. Every layer is verified at its own boundary, but that a real UI stop traverses these exact call sites is inference from the code, not observation. Worth exercising on a dev deploy before release.

The no-acknowledgement forward path is fixed but was not reachable from any first-party client — every client stop path uses emitWithAck. It closes a contract gap rather than an observed incident.

Unrelated to this branch: apps/server test:integration has 11 pre-existing failures across three sessionUpdateHandler.*.integration.spec.ts files. Reproduced on origin/dev with these sources reverted, so they are not caused by this PR.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Note

Clear session active state immediately on explicit stop disconnect

  • Adds a durable explicit-stop intent to the Session table via a new stopRequestedAt column, written when a runner accepts a killSession or stop RPC call in rpcHandler.ts.
  • When the publisher disconnects after a recorded stop intent, sessionPublisherPresence in sessionPublisherPresence.ts immediately closes the session and publishes an inactive lifecycle update with participant cursors, bypassing the normal presence timeout fence.
  • A 2-minute TTL bounds stop intent validity; new publisher registrations clear any stale stopRequestedAt so incidental disconnects on restarted sessions are not treated as explicit stops.
  • Risk: sessions that disconnect within 2 minutes of an accepted stop RPC will now close immediately rather than waiting for the presence timeout, which is a behavioral change for callers that expected the timeout-based path.

Macroscope summarized c486557.

Summary by CodeRabbit

  • Bug Fixes
    • Explicitly stopped sessions now become inactive immediately when their publisher disconnects.
    • Participant cursors, activity timestamps, and session projections are preserved during shutdown.
    • Stop requests are handled reliably, including legacy successful responses.
    • Unavailable session stops now return the appropriate unavailable-method response without recording a stop request.
    • Stop requests are safely consumed to prevent duplicate shutdown handling.
  • Tests
    • Added coverage for explicit stop handling, publisher closure, session state preservation, and unavailable stop requests.

An accepted stop kills the runner, but the resulting publisher disconnect
only recorded observer loss and left `active` set. Archive is gated on
`active === false`, so it returned 409 session-active for the full
10-minute presence fence while the client retry budget is 75s, making
archive-after-stop impossible to complete from the UI.

Record intent only when a runner accepts a stop, and treat the disconnect
that follows as the termination it is. A superseded fence still protects a
successor publisher, and a failed stop records nothing so an incidental
disconnect cannot end a live session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR records accepted session-stop intent and uses it to close the publisher’s presence fence on disconnect, then immediately broadcasts the inactive lifecycle state.

  • Adds accepted-response recognition for session- and machine-scoped stop RPCs.
  • Adds short-lived explicit-stop intent tracking to session publisher presence.
  • Adds disconnect lifecycle fanout and integration coverage for accepted and unreachable stop paths.

Confidence Score: 4/5

The cross-instance stop-intent handoff needs to be fixed before merging because multi-replica servers can still leave stopped sessions active until the timeout.

Accepted stop intent is stored only in the RPC caller’s memory, while Redis may place the runner publisher and its disconnect handler on another process that cannot observe that intent.

Files Needing Attention: apps/server/sources/app/api/socket/rpcHandler.ts; apps/server/sources/app/presence/sessionPublisherPresence.ts

Important Files Changed

Filename Overview
apps/server/sources/app/api/socket/rpcHandler.ts Records intent after an accepted stop response, but does so on the RPC caller’s process even when the runner is remote.
apps/server/sources/app/presence/sessionPublisherPresence.ts Adds fenced explicit-stop closure with TTL cleanup, while process-local intent storage does not support multi-instance routing.
apps/server/sources/app/api/socket.ts Publishes inactive lifecycle state when an explicit-stop disconnect successfully closes the publisher fence.
apps/server/sources/app/api/socket/rpcHandler.integration.spec.ts Verifies that an unreachable runner does not create explicit-stop intent.
apps/server/sources/app/presence/sessionPublisherPresence.sqlite.integration.spec.ts Covers same-process fenced closure and participant fanout but not cross-instance intent propagation.

Sequence Diagram

sequenceDiagram
  participant UI as UI socket / instance A
  participant A as Server instance A
  participant R as Redis RPC registry
  participant B as Server instance B
  participant D as Runner publisher
  UI->>A: killSession RPC
  A->>R: Route RPC
  R->>B: Resolve runner socket
  B->>D: Forward killSession
  D-->>A: accepted response
  A->>A: Store stop intent in local Map
  D--xB: Disconnect
  B->>B: Local Map has no intent
  B->>B: Record observer loss only
  Note over B: Session remains active until timeout
Loading

Reviews (1): Last reviewed commit: "fix(server-presence): clear session acti..." | Re-trigger Greptile

Comment on lines +384 to +388
const acceptedStopSessionId = sessionScopedStopSessionId ?? explicitMachineStopRequest?.sessionId ?? null;
if (acceptedStopSessionId && isAcceptedStopResponse(targetResponse)) {
ctx.sessionPublisherPresence?.markExplicitStopRequested({
sessionId: acceptedStopSessionId,
});

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.

P1 Stop intent stays on wrong instance

If the requesting client and runner publisher are connected to different server instances, the accepted RPC records intent only in the caller instance's process-local presence map. The runner's instance cannot consume that intent when its publisher disconnects, so the session remains active until the presence timeout and archive-after-stop continues to fail.

Knowledge Base Used: Happier Server (apps/server)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in dd6eb49. This was the correct read of the defect: the intent lived in explicitStopRequestedAtBySessionId, a process-local Map in createSessionPublisherPresence, so with redisRegistry.enabled the accepting RPC and the runner's publisher socket could be served by different instances and the disconnect handler never saw it. It degraded safely — no wrong closes — but delivered no improvement on multi-replica, which is where the archive-409 actually hurts.

The intent now lives on Session.stopRequestedAt (new nullable column, one migration per provider). markExplicitStopRequested writes the timestamp; forgetDisconnectedPublisher reads and clears it inside the transaction it already opens, so the consume and the close commit together. closeBindingAtFence was split into an in-transaction owner plus a thin inTx wrapper so the disconnect path reuses it without nesting a second transaction. The TTL is unchanged in meaning and is now a comparison against the persisted timestamp, which also retires the unbounded-map sweep.

Test: intent marked on one createSessionPublisherPresence instance is consumed by a second instance sharing the database. RED verified against the map — expected 'applied' to be 'closed'.

Two things the fix surfaced, both since fixed:

  • The intent is keyed by session, so a successor publisher that registered before the stopped one disconnected could consume its predecessor's intent — its own fence is current, so nothing rejected the close. Reproduced (expected 'closed' not to be 'closed') and fixed in 41fcedc by having registerOnce clear stopRequestedAt in the update it already performs.
  • The write had to be awaited in rpcHandler, since the disconnect that reads it can arrive as soon as the runner starts tearing down.

One honest limit on the evidence: the cross-instance behaviour is proven at the storage boundary by two presence instances sharing one database, not by two server processes with Redis actually enabled. Called out in the PR's "Not verified" section.

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The stop-session RPC path records accepted explicit stop requests. Publisher disconnect handling consumes matching requests, closes the publisher, and publishes an inactive lifecycle update with closure metadata and participant cursors.

Changes

Explicit session stop lifecycle

Layer / File(s) Summary
Record accepted stop intent
apps/server/prisma/..., apps/server/sources/app/api/socket/rpcHandler.ts, apps/server/sources/app/api/socket/rpcHandler.integration.spec.ts
The RPC handler identifies session-scoped stop requests and records accepted session- or machine-scoped responses. Prisma schemas and migrations persist stopRequestedAt. Integration tests cover unavailable and callback-free accepted stops across forwarding paths.
Correlate disconnect with stop intent
apps/server/sources/app/presence/sessionPublisherPresence.ts, apps/server/sources/app/presence/sessionPublisherPresence.sqlite.integration.spec.ts
Presence tracking applies a two-minute TTL and consume-once handling for explicit stop requests. Matching disconnects close the registered publisher at its committed fence. Tests verify terminal closure, cursor fanout, inactive state, preserved fence, unknown runtime activity, cross-instance consumption, and successor-publisher protection.
Publish immediate inactive state
apps/server/sources/app/api/socket.ts
Closed publishers now produce an immediate inactive lifecycle update with closure metadata and participant cursors.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant rpcHandler
  participant runner
  participant sessionPublisherPresence
  participant registeredPublisher
  participant socketDisconnectHandler
  participant sessionLifecycle
  rpcHandler->>runner: Forward stop request
  runner-->>rpcHandler: Return accepted response
  rpcHandler->>sessionPublisherPresence: markExplicitStopRequested
  sessionPublisherPresence->>sessionPublisherPresence: Persist stopRequestedAt
  registeredPublisher-->>socketDisconnectHandler: Disconnect
  socketDisconnectHandler->>sessionPublisherPresence: Consume matching request
  sessionPublisherPresence->>registeredPublisher: Close at committed fence
  registeredPublisher-->>socketDisconnectHandler: Return closed result and cursors
  socketDisconnectHandler->>sessionLifecycle: Publish inactive lifecycle update
Loading

Suggested reviewers: leeroybrun

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: clearing session activity after an explicit stop disconnect.
Description check ✅ Passed The description provides detailed problem, change, migration, testing, limitations, risks, and AI disclosure information, despite using different section headings than the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/server/sources/app/api/socket/rpcHandler.integration.spec.ts`:
- Around line 667-674: Update the rpcHandler test fixture to use the socket,
listener maps, context, and sessionPublisherPresence types expected by
rpcHandler instead of broad as any casts. Reuse or create a typed RPC handler
fixture and typed Map instances, keeping at most one narrowly scoped, documented
boundary cast only if required by the test harness.

In `@apps/server/sources/app/api/socket/rpcHandler.ts`:
- Around line 381-389: Update all three successful forwarding paths around
forwardTargetResponse so the response is always forwarded and records accepted
stop intent even when no acknowledgement callback is supplied; invoke callback
only when it exists. Add a regression test covering an accepted stop without a
callback and verify it fails before the production change, then passes after it.

In `@apps/server/sources/app/presence/sessionPublisherPresence.ts`:
- Around line 111-135: Replace the session-only explicit stop tracking in
markExplicitStopRequested and consumeExplicitStopRequest with a one-shot intent
bound to the target publisher identity and committed fence, stored or routed to
that publisher’s owning Socket.IO instance. Consume it only when that exact
publisher disconnects, preventing successor publishers from inheriting stale
stop intent. Add coverage for Redis-routed stops and successor replacement
before the original publisher disconnects.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8bf92beb-475c-4cfa-bdb5-37c5d22c70ce

📥 Commits

Reviewing files that changed from the base of the PR and between a313378 and dc1d3f9.

📒 Files selected for processing (5)
  • apps/server/sources/app/api/socket.ts
  • apps/server/sources/app/api/socket/rpcHandler.integration.spec.ts
  • apps/server/sources/app/api/socket/rpcHandler.ts
  • apps/server/sources/app/presence/sessionPublisherPresence.sqlite.integration.spec.ts
  • apps/server/sources/app/presence/sessionPublisherPresence.ts

Comment thread apps/server/sources/app/api/socket/rpcHandler.integration.spec.ts
Comment thread apps/server/sources/app/api/socket/rpcHandler.ts
Comment thread apps/server/sources/app/presence/sessionPublisherPresence.ts Outdated
danljungstrom and others added 2 commits August 2, 2026 21:40
The intent lived in a process-local Map inside `createSessionPublisherPresence`.
With `redisRegistry.enabled` the stop RPC and the runner's publisher socket can
be served by different instances, so the disconnect handler never saw the
intent and the fix degraded to the pre-existing behaviour: archive stays 409
`session-active` for the full 10-minute presence fence.

Move the intent to `Session.stopRequestedAt`. `markExplicitStopRequested`
writes the timestamp; `forgetDisconnectedPublisher` reads and clears it inside
the transaction it already opens, so the consume and the close commit together.
`closeBindingAtFence` is split into an in-transaction owner plus a wrapper so
the disconnect path reuses it without nesting a second transaction. The TTL is
unchanged in meaning and is now a comparison against the persisted timestamp,
which also retires the unbounded-map sweep.

The intent write is awaited in `rpcHandler` because it is now durable: the
publisher disconnect that reads it can arrive as soon as the runner begins
tearing down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er registers

`stopRequestedAt` is keyed by session, not by publisher, so a stop that never
killed its runner stayed readable after a successor took the session over. The
successor's own incidental disconnect then consumed it and closed a session
whose runner never agreed to stop — its fence was current, so nothing else
rejected the close.

A registering publisher is a new binding, so clear the intent in the update
`registerOnce` already performs. No extra query, and the fence check keeps
covering the opposite direction (a predecessor's stale fence).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/server/sources/app/presence/sessionPublisherPresence.ts (1)

560-579: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the explicit-stop consume-and-close block to reduce nesting.

This block adds two more nested levels inside an already deep forgetDisconnectedPublisher closure (arrow function → serialize callback → tryinTx callback → if (explicitStopRequested)if (closed.status === "closed")). Extract the stop-intent consumption and conditional close into a small named helper function, similar to how closeBindingAtFenceInTx was extracted from closeBindingAtFence. This keeps the disconnect handler flatter and makes the explicit-stop path independently testable.

As per coding guidelines, "Avoid deeply nested code; refactor into separate functions when nesting exceeds 3 levels."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/sources/app/presence/sessionPublisherPresence.ts` around lines
560 - 579, Extract the explicit-stop consumption and conditional binding closure
from forgetDisconnectedPublisher into a small named helper, using
consumeExplicitStopRequestInTx and closeBindingAtFenceInTx with the existing
session, binding, fence, timestamp, and mutation-id inputs. Have the helper
return the closed result when closure succeeds and preserve the current
fall-through behavior when no stop is consumed or the binding is already not
closed; replace the nested block with a call to this helper.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/server/sources/app/presence/sessionPublisherPresence.ts`:
- Around line 105-123: Bind durable stop intents to the publisher fence that
accepted them, not only the session timestamp: persist a stop-request fence from
captureExplicitMachineStop’s target.committedFence, and update
consumeExplicitStopRequestInTx to require registration.committedFence to match
before consuming or closing the session. Propagate the schema and call-site
changes through register/forget handling, and add an integration test in
sessionPublisherPresence.sqlite.integration.spec.ts covering a successor
registration followed by an unrelated disconnect.

---

Nitpick comments:
In `@apps/server/sources/app/presence/sessionPublisherPresence.ts`:
- Around line 560-579: Extract the explicit-stop consumption and conditional
binding closure from forgetDisconnectedPublisher into a small named helper,
using consumeExplicitStopRequestInTx and closeBindingAtFenceInTx with the
existing session, binding, fence, timestamp, and mutation-id inputs. Have the
helper return the closed result when closure succeeds and preserve the current
fall-through behavior when no stop is consumed or the binding is already not
closed; replace the nested block with a call to this helper.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 425b2f47-4934-4a4f-8f3f-72d06d796f41

📥 Commits

Reviewing files that changed from the base of the PR and between dc1d3f9 and dd6eb49.

📒 Files selected for processing (9)
  • apps/server/prisma/migrations/20260802220000_add_session_stop_requested_at/migration.sql
  • apps/server/prisma/mysql/migrations/20260802220000_add_session_stop_requested_at/migration.sql
  • apps/server/prisma/mysql/schema.prisma
  • apps/server/prisma/schema.prisma
  • apps/server/prisma/sqlite/migrations/20260802220000_add_session_stop_requested_at/migration.sql
  • apps/server/prisma/sqlite/schema.prisma
  • apps/server/sources/app/api/socket/rpcHandler.ts
  • apps/server/sources/app/presence/sessionPublisherPresence.sqlite.integration.spec.ts
  • apps/server/sources/app/presence/sessionPublisherPresence.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/server/sources/app/api/socket/rpcHandler.ts

Comment thread apps/server/sources/app/presence/sessionPublisherPresence.ts

@coderabbitai coderabbitai 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.

🧹 Nitpick comments (1)
apps/server/sources/app/presence/sessionPublisherPresence.sqlite.integration.spec.ts (1)

556-557: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the exact disconnect status instead of a negative match.

expect(disconnected.status).not.toBe("closed") only excludes one value. If forgetDisconnectedPublisher regresses and returns an unrelated status such as "rejected" or "superseded" for the wrong reason, this assertion still passes. Assert the specific expected status (for example "forgotten", or whatever the non-closing outcome is named) so the test fails when the code takes an unexpected path.

Proposed tightening
-        expect(disconnected.status).not.toBe("closed");
+        expect(disconnected.status).toBe("forgotten"); // replace with the actual non-closing status literal
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/server/sources/app/presence/sessionPublisherPresence.sqlite.integration.spec.ts`
around lines 556 - 557, Update the assertion for forgetDisconnectedPublisher in
the session presence integration test to require the exact expected non-closing
status, such as "forgotten" or the established equivalent, instead of merely
asserting that disconnected.status is not "closed".
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@apps/server/sources/app/presence/sessionPublisherPresence.sqlite.integration.spec.ts`:
- Around line 556-557: Update the assertion for forgetDisconnectedPublisher in
the session presence integration test to require the exact expected non-closing
status, such as "forgotten" or the established equivalent, instead of merely
asserting that disconnected.status is not "closed".

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b4bf45a9-1bdb-4745-bb10-b8d4eebd9129

📥 Commits

Reviewing files that changed from the base of the PR and between dd6eb49 and 41fcedc.

📒 Files selected for processing (2)
  • apps/server/sources/app/presence/sessionPublisherPresence.sqlite.integration.spec.ts
  • apps/server/sources/app/presence/sessionPublisherPresence.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/server/sources/app/presence/sessionPublisherPresence.ts

…ledgement

`forwardTargetResponse` owns explicit-stop intent recording and machine-scoped
stop finalization, but all three forward sites invoked it only inside
`if (callback)`. Socket.IO permits a caller to emit without an acknowledgement,
and such a stop was forwarded to the runner and accepted while the server
recorded nothing — leaving the session `active` for the full presence fence.

Evaluate the forwarded response on every successful forward and call back only
when the caller supplied one. Note that `callback?.(await forward(...))` does
not fix this: optional-call short-circuiting skips argument evaluation, so the
lifecycle never runs. The result is bound to a local first.

Not reachable from first-party clients today — every stop path uses
`emitWithAck` — so this closes the contract rather than an observed incident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/server/sources/app/api/socket/rpcHandler.ts (1)

379-395: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Close the accepted-response and disconnect race.

markExplicitStopRequested sets stopRequestedAt after forwardWithAck resolves, while forgetDisconnectedPublisher reads that field in its disconnect transaction. If a publisher disconnect starts after the target sends an accepted response but before the intent write commits, the disconnect can miss the explicit-stop correlation and leave the session inactive only after the presence fence. Coordinate an accepted-stop response with disconnect consumption, then add a regression test that races accepted response, intent write, and publisher disconnect.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/sources/app/api/socket/rpcHandler.ts` around lines 379 - 395,
Update forwardTargetResponse and the publisher-presence disconnect handling so
an accepted stop response is coordinated with markExplicitStopRequested before
forgetDisconnectedPublisher consumes disconnect state, preventing the disconnect
transaction from observing stale intent. Preserve correlation only for accepted
stops, and add a regression test that races the accepted response, intent write,
and publisher disconnect to verify the session is finalized correctly.
🧹 Nitpick comments (2)
apps/server/sources/app/api/socket/rpcHandler.integration.spec.ts (2)

689-718: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use four-space indentation in this server test.

The added block uses two spaces per indentation level. Reformat it with four spaces.

As per coding guidelines, files under apps/server/sources/**/*.{ts,tsx} use 4 spaces for indentation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/sources/app/api/socket/rpcHandler.integration.spec.ts` around
lines 689 - 718, Reformat the added test block in the `records an accepted stop
even when the caller sends no acknowledgement callback` test to use four spaces
for every indentation level, including nested callbacks, object literals, and
assertions; do not change its behavior.

Source: Coding guidelines


689-718: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover all forwarding branches in the no-ack regression.

This test exercises only the single-process listener-map path. The production change also covers the fallback path at Lines [532-536] and the Redis path at Lines [632-633]. Add equivalent no-ack cases for those branches, or parameterize the fixture, and assert that markExplicitStopRequested runs in each branch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/server/sources/app/api/socket/rpcHandler.integration.spec.ts` around
lines 689 - 718, Extend the no-acknowledgement regression coverage for the
forwarding logic to include the fallback listener path and Redis path in
addition to the listener-map case. Reuse or parameterize the existing fixture
around rpcHandler and assert that each branch invokes markExplicitStopRequested
with the session ID after the runner accepts the stop.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@apps/server/sources/app/api/socket/rpcHandler.ts`:
- Around line 379-395: Update forwardTargetResponse and the publisher-presence
disconnect handling so an accepted stop response is coordinated with
markExplicitStopRequested before forgetDisconnectedPublisher consumes disconnect
state, preventing the disconnect transaction from observing stale intent.
Preserve correlation only for accepted stops, and add a regression test that
races the accepted response, intent write, and publisher disconnect to verify
the session is finalized correctly.

---

Nitpick comments:
In `@apps/server/sources/app/api/socket/rpcHandler.integration.spec.ts`:
- Around line 689-718: Reformat the added test block in the `records an accepted
stop even when the caller sends no acknowledgement callback` test to use four
spaces for every indentation level, including nested callbacks, object literals,
and assertions; do not change its behavior.
- Around line 689-718: Extend the no-acknowledgement regression coverage for the
forwarding logic to include the fallback listener path and Redis path in
addition to the listener-map case. Reuse or parameterize the existing fixture
around rpcHandler and assert that each branch invokes markExplicitStopRequested
with the session ID after the runner accepts the stop.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7eb05681-51fe-4bc7-b2ae-31609f915d9e

📥 Commits

Reviewing files that changed from the base of the PR and between 41fcedc and c486557.

📒 Files selected for processing (2)
  • apps/server/sources/app/api/socket/rpcHandler.integration.spec.ts
  • apps/server/sources/app/api/socket/rpcHandler.ts

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