camofox-browser/tests/unit/sessionCleanup.test.js

369 lines
12 KiB
JavaScript

/**
* Unit tests for session cleanup race conditions.
*
* Covers:
* 1. Tab reaper → empty session cleanup (with _closing flag)
* 2. getSession() skips sessions marked _closing
* 3. YT transcript cleanup uses context.pages() instead of tabGroups
* 4. Session expiry sets _closing before teardown
*/
describe('session cleanup after tab reaper', () => {
// Simulate the reaper loop logic from server.js (with _closing flag)
function runTabReaper({ sessions, TAB_INACTIVITY_MS, destroyTab, onSessionEmpty }) {
const now = Date.now();
for (const [userId, session] of sessions) {
for (const [listItemId, group] of session.tabGroups) {
for (const [tabId, tabState] of group) {
if (!tabState._lastReaperCheck) {
tabState._lastReaperCheck = now;
tabState._lastReaperToolCalls = tabState.toolCalls;
continue;
}
if (tabState.toolCalls === tabState._lastReaperToolCalls) {
const idleMs = now - tabState._lastReaperCheck;
if (idleMs >= TAB_INACTIVITY_MS) {
destroyTab(tabId);
group.delete(tabId);
}
} else {
tabState._lastReaperCheck = now;
tabState._lastReaperToolCalls = tabState.toolCalls;
}
}
if (group.size === 0) {
session.tabGroups.delete(listItemId);
}
}
if (session.tabGroups.size === 0) {
session._closing = true;
onSessionEmpty(userId);
sessions.delete(userId);
}
}
}
function makeSession(tabs) {
const tabGroups = new Map();
const group = new Map();
for (const [tabId, tabState] of Object.entries(tabs)) {
group.set(tabId, { toolCalls: 0, ...tabState });
}
tabGroups.set('list-1', group);
return { tabGroups, lastAccess: Date.now() };
}
test('empty session is cleaned up when all tabs are reaped', () => {
const past = Date.now() - 600_000; // 10 min ago
const sessions = new Map();
sessions.set('user-1', makeSession({
'tab-1': { _lastReaperCheck: past, _lastReaperToolCalls: 0, toolCalls: 0 },
'tab-2': { _lastReaperCheck: past, _lastReaperToolCalls: 0, toolCalls: 0 },
}));
const destroyed = [];
const emptied = [];
runTabReaper({
sessions,
TAB_INACTIVITY_MS: 300_000,
destroyTab: (id) => destroyed.push(id),
onSessionEmpty: (userId) => emptied.push(userId),
});
expect(destroyed).toEqual(['tab-1', 'tab-2']);
expect(emptied).toEqual(['user-1']);
expect(sessions.size).toBe(0);
});
test('reaped session gets _closing flag set before deletion', () => {
const past = Date.now() - 600_000;
const sessions = new Map();
const session = makeSession({
'tab-1': { _lastReaperCheck: past, _lastReaperToolCalls: 0, toolCalls: 0 },
});
sessions.set('user-1', session);
let closingFlagAtCallback = null;
runTabReaper({
sessions,
TAB_INACTIVITY_MS: 300_000,
destroyTab: () => {},
onSessionEmpty: () => { closingFlagAtCallback = session._closing; },
});
// _closing should be set BEFORE the onSessionEmpty callback
expect(closingFlagAtCallback).toBe(true);
});
test('session with active tabs is NOT cleaned up', () => {
const past = Date.now() - 600_000;
const sessions = new Map();
sessions.set('user-1', makeSession({
'tab-1': { _lastReaperCheck: past, _lastReaperToolCalls: 0, toolCalls: 0 },
'tab-2': { _lastReaperCheck: past, _lastReaperToolCalls: 0, toolCalls: 5 }, // active
}));
const destroyed = [];
const emptied = [];
runTabReaper({
sessions,
TAB_INACTIVITY_MS: 300_000,
destroyTab: (id) => destroyed.push(id),
onSessionEmpty: (userId) => emptied.push(userId),
});
expect(destroyed).toEqual(['tab-1']);
expect(emptied).toEqual([]);
expect(sessions.size).toBe(1);
const session = sessions.get('user-1');
expect(session._closing).toBeUndefined();
expect(session.tabGroups.get('list-1').has('tab-2')).toBe(true);
});
test('multiple sessions: only empty ones are cleaned up', () => {
const past = Date.now() - 600_000;
const sessions = new Map();
sessions.set('user-1', makeSession({
'tab-1': { _lastReaperCheck: past, _lastReaperToolCalls: 0, toolCalls: 0 },
}));
sessions.set('user-2', makeSession({
'tab-2': { _lastReaperCheck: past, _lastReaperToolCalls: 0, toolCalls: 3 }, // active
}));
const emptied = [];
runTabReaper({
sessions,
TAB_INACTIVITY_MS: 300_000,
destroyTab: () => {},
onSessionEmpty: (userId) => emptied.push(userId),
});
expect(emptied).toEqual(['user-1']);
expect(sessions.size).toBe(1);
expect(sessions.has('user-2')).toBe(true);
});
test('tabs not yet checked are skipped (first pass initializes reaper state)', () => {
const sessions = new Map();
sessions.set('user-1', makeSession({
'tab-1': { toolCalls: 0 }, // no _lastReaperCheck
}));
const destroyed = [];
const emptied = [];
runTabReaper({
sessions,
TAB_INACTIVITY_MS: 300_000,
destroyTab: (id) => destroyed.push(id),
onSessionEmpty: (userId) => emptied.push(userId),
});
expect(destroyed).toEqual([]);
expect(emptied).toEqual([]);
expect(sessions.size).toBe(1);
});
});
describe('getSession _closing flag handling', () => {
// Simulate getSession logic from server.js
function getSession(sessions, userId, createContext) {
const key = String(userId);
let session = sessions.get(key);
if (session) {
if (session._closing) {
session = null;
} else {
try {
session.context.pages();
} catch {
sessions.delete(key);
session = null;
}
}
}
if (!session) {
const context = createContext();
session = { context, tabGroups: new Map(), lastAccess: Date.now() };
sessions.set(key, session);
}
session.lastAccess = Date.now();
return session;
}
test('returns existing session when context is alive', () => {
const sessions = new Map();
const existingContext = { pages: () => [] };
sessions.set('user-1', { context: existingContext, tabGroups: new Map(), lastAccess: 0 });
const result = getSession(sessions, 'user-1', () => { throw new Error('should not create'); });
expect(result.context).toBe(existingContext);
});
test('skips session with _closing flag and creates new one', () => {
const sessions = new Map();
const oldContext = { pages: () => [] };
sessions.set('user-1', { context: oldContext, tabGroups: new Map(), lastAccess: 0, _closing: true });
const newContext = { pages: () => [] };
const result = getSession(sessions, 'user-1', () => newContext);
expect(result.context).toBe(newContext);
expect(result.context).not.toBe(oldContext);
expect(result._closing).toBeUndefined();
// Old entry is replaced in the map
expect(sessions.get('user-1').context).toBe(newContext);
});
test('recreates session when context.pages() throws', () => {
const sessions = new Map();
const deadContext = { pages: () => { throw new Error('context closed'); } };
sessions.set('user-1', { context: deadContext, tabGroups: new Map(), lastAccess: 0 });
const newContext = { pages: () => [] };
const result = getSession(sessions, 'user-1', () => newContext);
expect(result.context).toBe(newContext);
});
test('creates fresh session when none exists', () => {
const sessions = new Map();
const newContext = { pages: () => [] };
const result = getSession(sessions, 'user-1', () => newContext);
expect(result.context).toBe(newContext);
expect(sessions.has('user-1')).toBe(true);
});
});
describe('YT transcript session cleanup', () => {
// Simulate the finally-block cleanup logic from browserTranscript
function ytCleanup(sessions, ytKey, contextPagesResult, contextPagesThrows) {
const ytSession = sessions.get(ytKey);
if (ytSession && !ytSession._closing) {
try {
if (contextPagesThrows) throw new Error('context closed');
const remainingPages = contextPagesResult;
if (remainingPages.length === 0) {
ytSession._closing = true;
// context.close() would be called here
sessions.delete(ytKey);
}
} catch {
sessions.delete(ytKey);
}
}
}
test('does NOT close session when other pages are still open', () => {
const sessions = new Map();
const session = { context: {}, tabGroups: new Map(), lastAccess: Date.now() };
sessions.set('__yt_transcript__', session);
// Another transcript request still has a page open
ytCleanup(sessions, '__yt_transcript__', [{ /* page */ }], false);
expect(sessions.has('__yt_transcript__')).toBe(true);
expect(session._closing).toBeUndefined();
});
test('closes session when no pages remain', () => {
const sessions = new Map();
const session = { context: {}, tabGroups: new Map(), lastAccess: Date.now() };
sessions.set('__yt_transcript__', session);
ytCleanup(sessions, '__yt_transcript__', [], false);
expect(sessions.has('__yt_transcript__')).toBe(false);
expect(session._closing).toBe(true);
});
test('cleans up map entry when context is already dead', () => {
const sessions = new Map();
const session = { context: {}, tabGroups: new Map(), lastAccess: Date.now() };
sessions.set('__yt_transcript__', session);
ytCleanup(sessions, '__yt_transcript__', [], true /* throws */);
expect(sessions.has('__yt_transcript__')).toBe(false);
});
test('skips cleanup when session is already _closing', () => {
const sessions = new Map();
const session = { context: {}, tabGroups: new Map(), lastAccess: Date.now(), _closing: true };
sessions.set('__yt_transcript__', session);
ytCleanup(sessions, '__yt_transcript__', [], false);
// Session is still in the map (another cleanup path owns it)
expect(sessions.has('__yt_transcript__')).toBe(true);
});
test('concurrent requests: first closer sees pages, second sees empty', () => {
const sessions = new Map();
const session = { context: {}, tabGroups: new Map(), lastAccess: Date.now() };
sessions.set('__yt_transcript__', session);
// Request A finishes first — request B still has a page
ytCleanup(sessions, '__yt_transcript__', [{ /* B's page */ }], false);
expect(sessions.has('__yt_transcript__')).toBe(true);
expect(session._closing).toBeUndefined();
// Request B finishes — no pages left
ytCleanup(sessions, '__yt_transcript__', [], false);
expect(sessions.has('__yt_transcript__')).toBe(false);
expect(session._closing).toBe(true);
});
});
describe('session expiry _closing flag', () => {
// Simulate session expiry logic from server.js
function runSessionExpiry({ sessions, SESSION_TIMEOUT_MS, onExpired }) {
const now = Date.now();
for (const [userId, session] of sessions) {
if (now - session.lastAccess > SESSION_TIMEOUT_MS) {
session._closing = true;
onExpired(userId);
sessions.delete(userId);
}
}
}
test('expired session gets _closing flag before deletion', () => {
const past = Date.now() - 600_000;
const sessions = new Map();
const session = { tabGroups: new Map(), lastAccess: past };
sessions.set('user-1', session);
let closingFlagAtCallback = null;
runSessionExpiry({
sessions,
SESSION_TIMEOUT_MS: 300_000,
onExpired: () => { closingFlagAtCallback = session._closing; },
});
expect(closingFlagAtCallback).toBe(true);
expect(sessions.size).toBe(0);
});
test('active session is NOT expired or flagged', () => {
const sessions = new Map();
const session = { tabGroups: new Map(), lastAccess: Date.now() };
sessions.set('user-1', session);
runSessionExpiry({
sessions,
SESSION_TIMEOUT_MS: 300_000,
onExpired: () => { throw new Error('should not expire'); },
});
expect(sessions.size).toBe(1);
expect(session._closing).toBeUndefined();
});
});