Skip to content

fix: destroySession race with concurrent getOrCreateSession (#106)#107

Merged
fitz123 merged 1 commit intomainfrom
fix/issue-106-destroy-session-race
Apr 19, 2026
Merged

fix: destroySession race with concurrent getOrCreateSession (#106)#107
fitz123 merged 1 commit intomainfrom
fix/issue-106-destroy-session-race

Conversation

@fitz123
Copy link
Copy Markdown
Owner

@fitz123 fitz123 commented Apr 19, 2026

Summary

Why

destroySession awaited closeSession, which persisted a fresh snapshot on its way out. During the 0-5s SIGTERM to exit window, a concurrent getOrCreateSession for the same chatId could read that snapshot and spawn with --resume, reviving the session the user just /cleaned. Root cause and reproduction in #106.

Test plan

  • npx tsx --test bot/src/tests/session-manager.test.ts -> 60/60 pass
  • New test destroySession removes stored state before awaiting child exit (race-safe) fails without the ordering fix
  • Full bot test suite in CI

Closes #106.

destroySession awaited closeSession before calling store.deleteSession,
and closeSession always persisted a fresh snapshot on its way out. During
the ~0-5s child-exit await window a concurrent getOrCreateSession for the
same chatId could read that just-persisted snapshot from store and spawn a
--resume, reviving the session the user just tried to /clean.

Delete from the store first, then close with { persist: false } so the
persist step never re-adds the entry. Media cleanup still runs afterward.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings April 19, 2026 12:47
@fitz123 fitz123 merged commit 5d87a32 into main Apr 19, 2026
3 checks passed
@fitz123 fitz123 deleted the fix/issue-106-destroy-session-race branch April 19, 2026 12:48
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a race where /clean (destroySession) could leave a resumable snapshot in the session store while awaiting a child process exit, allowing a concurrent getOrCreateSession to revive the just-destroyed session.

Changes:

  • Added a { persist?: boolean } option to closeSession() and gated the final store write behind it.
  • Reordered destroySession() to delete stored state before awaiting child exit, and invoked closeSession(..., { persist: false }) to avoid re-persisting.
  • Added a race-focused unit test that injects a slow-exiting child and asserts the store is empty mid-destroy.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
bot/src/session-manager.ts Reorders destroy vs. close and adds optional persistence to prevent store resurrection during shutdown await.
bot/src/__tests__/session-manager.test.ts Adds a regression test validating store deletion happens before the child-exit await completes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -454,7 +454,7 @@ export class SessionManager {
}

/** Close a session: persist state, SIGTERM child, clean up. */
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

The JSDoc says closeSession always persists state, but the new { persist } option means it may intentionally skip persistence. Update the doc comment to reflect the optional persistence behavior (and what persist: false implies) so callers aren’t misled.

Suggested change
/** Close a session: persist state, SIGTERM child, clean up. */
/** Close a session: optionally persist state, SIGTERM child, and clean up.
* By default the final session state is written to the store before teardown.
* Pass `{ persist: false }` to skip that write when the caller intentionally
* does not want the final in-memory state persisted (for example, to avoid races).
*/

Copilot uses AI. Check for mistakes.
injectDir,
};

(manager as unknown as { active: Map<string, ActiveSession> }).active.set("chat-race", fakeSession);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

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

This test injects a fake session directly into manager.active but doesn’t increment sessionsActive; closeSession() will call sessionsActive.dec() and can push the global gauge negative, which may leak state across tests. Consider incrementing the gauge when inserting the fake session (or resetting metrics in this suite) to keep the metric consistent.

Copilot uses AI. Check for mistakes.
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.

destroySession can race with concurrent getOrCreateSession during child-exit await

2 participants