369 lines
12 KiB
JavaScript
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();
|
|
});
|
|
});
|