Skip to content

Commit 7114593

Browse files
authored
fix: mark as idle only when definitely idle (#2111)
1 parent b0f2232 commit 7114593

File tree

4 files changed

+80
-15
lines changed

4 files changed

+80
-15
lines changed

.changeset/lucky-sheep-create.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-js': patch
3+
---
4+
5+
checks for session activity in other windows when timing out in any particular window, avoids a race condition when proactively marking a session as idle

packages/browser/src/__tests__/sessionid.test.ts

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,10 @@ describe('Session ID manager', () => {
3939

4040
persistence = {
4141
props: { [SESSION_ID]: undefined },
42-
register: jest.fn(),
42+
register: jest.fn().mockImplementation((props) => {
43+
// Mock the behavior of register - it should update the props
44+
Object.assign(persistence.props, props)
45+
}),
4346
_disabled: false,
4447
}
4548
;(sessionStore._is_supported as jest.Mock).mockReturnValue(true)
@@ -393,22 +396,69 @@ describe('Session ID manager', () => {
393396
expect(sessionIdManager['_enforceIdleTimeout']).toEqual(originalTimer)
394397
})
395398

396-
/** timer doesn't advance and fire this? */
397-
it.skip('resets session id despite no activity after timeout', () => {
398-
;(uuidv7 as jest.Mock).mockImplementationOnce(() => 'originalUUID')
399+
it('resets session when idle timeout is exceeded', async () => {
400+
jest.useFakeTimers()
399401

400402
const sessionIdManager = sessionIdMgr(persistence)
401-
const { sessionId: originalSessionId } = sessionIdManager.checkAndGetSessionAndWindowId(
402-
undefined,
403-
timestamp
404-
)
405-
expect(originalSessionId).toBeDefined()
403+
const resetSpy = jest.spyOn(sessionIdManager, 'resetSessionId')
404+
405+
// Start with a fresh session
406+
sessionIdManager.checkAndGetSessionAndWindowId(false, timestamp)
407+
408+
// Set up persistence to simulate inactivity - session was last active long ago
409+
const idleTimestamp = timestamp - (sessionIdManager.sessionTimeoutMs + 1000)
410+
persistence.props[SESSION_ID] = [idleTimestamp, 'oldSessionID', idleTimestamp]
411+
412+
// Fast-forward time to trigger the idle timeout timer
413+
const idleTimeoutMs = sessionIdManager.sessionTimeoutMs * 1.1
414+
jest.advanceTimersByTime(idleTimeoutMs + 1000)
415+
416+
// Timer should have fired and called resetSessionId
417+
expect(resetSpy).toHaveBeenCalled()
418+
419+
// After reset, persistence.register should have been called with null values
420+
expect(persistence.register).toHaveBeenCalledWith({ [SESSION_ID]: [null, null, null] })
421+
422+
// Next call should generate a new session due to no session ID
423+
const newSessionData = sessionIdManager.checkAndGetSessionAndWindowId(false)
424+
expect(newSessionData.sessionId).toBe('newUUID')
425+
expect(newSessionData.sessionId).not.toEqual('oldSessionID')
426+
expect(newSessionData.changeReason?.noSessionId).toBe(true)
427+
428+
jest.useRealTimers()
429+
})
430+
431+
it('timer checks current session activity before resetting', async () => {
432+
jest.useFakeTimers()
433+
434+
const sessionIdManager = sessionIdMgr(persistence)
435+
const resetSpy = jest.spyOn(sessionIdManager, 'resetSessionId')
436+
437+
// Mock _getSessionId to control what the timer sees
438+
const getSessionIdSpy = jest.spyOn(sessionIdManager as any, '_getSessionId')
439+
440+
// Start with a fresh session
441+
sessionIdManager.checkAndGetSessionAndWindowId(false, timestamp)
442+
443+
// Initially set up an idle session
444+
const idleTimestamp = timestamp - (sessionIdManager.sessionTimeoutMs + 1000)
445+
getSessionIdSpy.mockReturnValue([idleTimestamp, 'sharedSessionID', timestamp])
446+
447+
// Fast-forward time almost to when timer fires
448+
const idleTimeoutMs = sessionIdManager.sessionTimeoutMs * 1.1
449+
jest.advanceTimersByTime(idleTimeoutMs - 100)
450+
451+
// Before timer fires, change mock to return recent activity (simulating another window updating)
452+
const recentTimestamp = new Date().getTime() - 1000 // 1 second ago
453+
getSessionIdSpy.mockReturnValue([recentTimestamp, 'sharedSessionID', timestamp])
454+
455+
// Let the timer fire
456+
jest.advanceTimersByTime(200)
406457

407-
jest.advanceTimersByTime(DEFAULT_SESSION_IDLE_TIMEOUT_SECONDS * 1.1 + 1)
458+
// The timer should NOT have reset the session because it found recent activity
459+
expect(resetSpy).not.toHaveBeenCalled()
408460

409-
const { sessionId: finalSessionId } = sessionIdManager.checkAndGetSessionAndWindowId(undefined, timestamp)
410-
expect(finalSessionId).toBeDefined()
411-
expect(finalSessionId).not.toEqual(originalSessionId)
461+
jest.useRealTimers()
412462
})
413463
})
414464
})

packages/browser/src/sessionid.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@ export class SessionIdManager {
212212
)
213213
}
214214

215+
private _sessionHasBeenIdleTooLong = (timestamp: number, lastActivityTimestamp: number) => {
216+
return Math.abs(timestamp - lastActivityTimestamp) > this.sessionTimeoutMs
217+
}
218+
215219
/*
216220
* This function returns the current sessionId and windowId. It should be used to
217221
* access these values over directly calling `._sessionId` or `._windowId`.
@@ -247,7 +251,7 @@ export class SessionIdManager {
247251

248252
let valuesChanged = false
249253
const noSessionId = !sessionId
250-
const activityTimeout = !readOnly && Math.abs(timestamp - lastActivityTimestamp) > this.sessionTimeoutMs
254+
const activityTimeout = !readOnly && this._sessionHasBeenIdleTooLong(timestamp, lastActivityTimestamp)
251255
if (noSessionId || activityTimeout || sessionPastMaximumLength) {
252256
sessionId = this._sessionIdGenerator()
253257
windowId = this._windowIdGenerator()
@@ -297,7 +301,12 @@ export class SessionIdManager {
297301
clearTimeout(this._enforceIdleTimeout)
298302
this._enforceIdleTimeout = setTimeout(() => {
299303
// enforce idle timeout a little after the session timeout to ensure the session is reset even without activity
300-
this.resetSessionId()
304+
// we need to check session activity first in case a different window has kept the session active
305+
// while this window has been idle - and the timer has not progressed - e.g. window memory frozen while hidden
306+
const [lastActivityTimestamp] = this._getSessionId()
307+
if (this._sessionHasBeenIdleTooLong(new Date().getTime(), lastActivityTimestamp)) {
308+
this.resetSessionId()
309+
}
301310
}, this.sessionTimeoutMs * 1.1)
302311
}
303312
}

packages/browser/terser-mangled-names.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@
252252
"_send_request",
253253
"_send_retriable_request",
254254
"_sessionDuration",
255+
"_sessionHasBeenIdleTooLong",
255256
"_sessionId",
256257
"_sessionIdChangedHandlers",
257258
"_sessionIdGenerator",

0 commit comments

Comments
 (0)