Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions core/src/session-heartbeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,17 +123,34 @@ function registerAutoLogout() {
return
}

const logoutCheckInterval = 1000
let lastActive = Date.now()
window.addEventListener('mousemove', () => {
let lastStored = 0
const markActive = () => {
lastActive = Date.now()
localStorage.setItem('lastActive', JSON.stringify(lastActive))
})

window.addEventListener('touchstart', () => {
lastActive = Date.now()
localStorage.setItem('lastActive', JSON.stringify(lastActive))
})
if (lastActive - lastStored < logoutCheckInterval) {
return
}
lastStored = lastActive
try {
localStorage.setItem('lastActive', JSON.stringify(lastActive))
} catch (error) {
logger.debug('Could not share the last activity with other tabs', { error })
}
}

// Capture phase, as editors may stop the propagation of input events
window.addEventListener('mousemove', markActive, { capture: true })
// Touch, pen and clicks, including clicks by assistive technology that do not move the mouse
window.addEventListener('pointerdown', markActive, { capture: true })
window.addEventListener('keydown', markActive, { capture: true })
// Text insertion without key events, e.g. dictation
window.addEventListener('beforeinput', markActive, { capture: true })
// Wheel instead of scroll events, as scripts fire scroll events too
window.addEventListener('wheel', markActive, { capture: true, passive: true })
Comment on lines +142 to +150

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.

I am unsure if this is not too much of events, did you do a performance analysis?

If there is a bad performance impact here, we might need to trim this down to

  • mousemove
  • touchstart (mobile)
  • keydown (typing or navigating with keyboard)
  • wheel (scrolling long documents)

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.

Didn’t profile, but looking at the code this should actually be cheaper than before. On master every mousemove wrote to localStorage. Now each event only updates a timestamp, and localStorage gets written at most once per second.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@pringelmann That's correct. But this code is only active when auto_logout is on, which is important to remember.
@susnux I did some testing with Claude after your suggestion and uses about 10 times less CPU resources when moving the mouse. Full report:

Firefox master Firefox PR Firefox suggestion Chrome master Chrome PR Chrome suggestion
Initialization, once per page load (µs) 23.3 26.7 26.7 81.3 88.0 83.0
Page load, median (DOMContentLoaded / file list visible, ms) 1116 / 1550 1110 / 1546 1085 / 1515 1244 / 1498 1213 / 1484 1224 / 1506
Each storage event in another tab (µs) 2.18 2.18 2.18 1.19 1.19 1.19
Moving the mouse (60 mousemove/s) 193 ± 51 + 392 = 585 (60/s) 41 ± 26 + 6 = 48 (1/s) 41 ± 26 + 6 = 48 (1/s) 312 ± 10 + 214 = 527 (60/s) 42 ± 11 + 4 = 46 (1/s) 36 ± 23 + 4 = 39 (1/s)
Scrolling with wheel or trackpad (60 wheel/s) 0 ± 1 + 0 = 0 ✗ (0/s) 34 ± 1 + 6 = 40 (1/s) 34 ± 2 + 6 = 40 (1/s) -1 ± 0 + 0 = -1 ✗ (0/s) 41 ± 2 + 4 = 44 (1/s) 41 ± 1 + 4 = 44 (1/s)
Typing (6 keydown + 6 beforeinput/s) 3 ± 4 + 0 = 3 ✗ (0/s) 12 ± 10 + 6 = 18 (1/s) 11 ± 16 + 6 = 18 (1/s) -6 ± 4 + 0 = -6 ✗ (0/s) 31 ± 10 + 4 = 35 (1/s) 24 ± 7 + 4 = 27 (1/s)
Touch scrolling (1 swipe/s) 6 ± 1 + 6 = 12 (1/s) 6 ± 1 + 6 = 12 (1/s) 6 ± 1 + 6 = 12 (1/s) 6 ± 0 + 4 = 9 (1/s) 8 ± 1 + 4 = 11 (1/s) 8 ± 1 + 4 = 11 (1/s)
Mouse + scrolling + typing at once 180 ± 24 + 392 = 572 (60/s) 48 ± 21 + 6 = 54 (1/s) 54 ± 29 + 6 = 60 (1/s) 289 ± 30 + 214 = 504 (60/s) 140 ± 35 + 4 = 144 (1/s) 138 ± 48 + 4 = 142 (1/s)

How to read a use-case cell: CPU time in µs per second of that activity, as this tab ± noise + 3 other open Nextcloud tabs = total, with localStorage writes per second (each one reaches every other tab) in brackets. ✗ means master doesn't detect this activity, so the user gets logged out. Negative values are noise around zero. One second of one CPU core is 1,000,000 µs.


// Besides other tabs, apps with cross-origin editors (e.g. richdocuments) dispatch
// this event in the same window to report activity inside their iframes
window.addEventListener('storage', (event) => {
if (event.key !== 'lastActive') {
return
Expand All @@ -154,5 +171,5 @@ function registerAutoLogout() {
window.location.href = logoutUrl
}
}
intervalId = window.setInterval(logoutCheck, 1000)
intervalId = window.setInterval(logoutCheck, logoutCheckInterval)
}
183 changes: 182 additions & 1 deletion core/src/tests/OC/session-heartbeat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,27 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NextcloudUser } from '@nextcloud/auth'
import type { MockInstance } from 'vitest'

import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'

const requestToken = vi.hoisted(() => ({
fetchRequestToken: vi.fn<() => Promise<string>>(),
getRequestToken: vi.fn<() => string>(),
setRequestToken: vi.fn<(token: string) => void>(),
}))
vi.mock('../../OC/requesttoken.ts', () => requestToken)

const initialState = vi.hoisted(() => ({ loadState: vi.fn() }))
vi.mock('@nextcloud/initial-state', () => initialState)

const auth = vi.hoisted(() => ({ getCurrentUser: vi.fn<() => NextcloudUser | null>(() => null) }))
vi.mock('@nextcloud/auth', () => auth)

describe('Session heartbeat', () => {
let addEventListener: MockInstance<typeof window.addEventListener>

beforeAll(() => {
vi.useFakeTimers()
})
Expand All @@ -23,6 +32,15 @@ describe('Session heartbeat', () => {
vi.clearAllTimers()
vi.resetModules()
vi.resetAllMocks()
addEventListener = vi.spyOn(window, 'addEventListener')
})

afterEach(() => {
// Each test imports a new module instance, so its listeners must not outlive the test
for (const listener of addEventListener.mock.calls) {
window.removeEventListener(...listener)
}
vi.restoreAllMocks()
})

it('sends heartbeat half the session lifetime when heartbeat enabled', async () => {
Expand Down Expand Up @@ -120,4 +138,167 @@ describe('Session heartbeat', () => {
await vi.advanceTimersByTimeAsync(60 * 60 * 1000)
expect(requestToken.fetchRequestToken).toHaveBeenCalledOnce()
})

describe('auto logout', () => {
const pageUrl = 'http://nextcloud.local/apps/text'
const logoutUrl = '/index.php/logout?requesttoken=csrf-token'
let originalLocation: Location

beforeEach(() => {
originalLocation = window.location
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: { href: pageUrl },
})

auth.getCurrentUser.mockReturnValue({ uid: 'user', displayName: 'User', isAdmin: false })
requestToken.getRequestToken.mockReturnValue('csrf-token')
initialState.loadState.mockImplementationOnce(() => ({
auto_logout: true,
session_lifetime: 120,
}))
})

afterEach(() => {
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: originalLocation,
})
document.body.replaceChildren()
localStorage.clear()
})

/**
* Dispatch the activity before the session lifetime is over and
* expect the logout to happen one session lifetime after it.
*
* @param dispatchActivity - Callback dispatching the user activity
*/
async function expectActivity(dispatchActivity: () => void) {
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
initSessionHeartBeat()

await vi.advanceTimersByTimeAsync(100 * 1000)
dispatchActivity()

await vi.advanceTimersByTimeAsync(120 * 1000)
expect(window.location.href).toBe(pageUrl)

await vi.advanceTimersByTimeAsync(1000)
expect(window.location.href).toBe(logoutUrl)
}

it('logs out once the session lifetime passed without activity', async () => {
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
initSessionHeartBeat()

await vi.advanceTimersByTimeAsync(120 * 1000)
expect(window.location.href).toBe(pageUrl)

await vi.advanceTimersByTimeAsync(1000)
expect(window.location.href).toBe(logoutUrl)
})

it.each([
{ activity: 'mouse movement', createEvent: () => new MouseEvent('mousemove', { bubbles: true }) },
{ activity: 'touch', createEvent: () => new PointerEvent('pointerdown', { bubbles: true, pointerType: 'touch' }) },
{ activity: 'click without mouse movement', createEvent: () => new PointerEvent('pointerdown', { bubbles: true, pointerType: 'mouse' }) },
{ activity: 'key press', createEvent: () => new KeyboardEvent('keydown', { bubbles: true }) },
{ activity: 'text input', createEvent: () => new InputEvent('beforeinput', { bubbles: true, inputType: 'insertText', data: 'a' }) },
{ activity: 'wheel scrolling', createEvent: () => new WheelEvent('wheel', { bubbles: true }) },
])('counts $activity as activity, even when an editor stops its propagation', async ({ createEvent }) => {
const activityEvent = createEvent()
const editor = document.createElement('div')
editor.addEventListener(activityEvent.type, (event) => event.stopPropagation())
document.body.appendChild(editor)

await expectActivity(() => editor.dispatchEvent(activityEvent))
})

it('does not delay scrolling with its wheel listener', async () => {
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
initSessionHeartBeat()

expect(addEventListener).toHaveBeenCalledWith('wheel', expect.any(Function), { capture: true, passive: true })
})

it('counts activity reported by apps through a storage event in the same window', async () => {
await expectActivity(() => window.dispatchEvent(new StorageEvent('storage', {
key: 'lastActive',
newValue: Date.now().toString(),
})))
})

it('does not count scrolling done by scripts as activity', async () => {
const element = document.createElement('div')
document.body.appendChild(element)

const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
initSessionHeartBeat()

await vi.advanceTimersByTimeAsync(100 * 1000)
element.dispatchEvent(new Event('scroll'))

await vi.advanceTimersByTimeAsync(21 * 1000)
expect(window.location.href).toBe(logoutUrl)
})

it('stores the last activity at most once per second', async () => {
const start = Date.now()
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
initSessionHeartBeat()

await vi.advanceTimersByTimeAsync(100 * 1000)
document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
expect(localStorage.getItem('lastActive')).toBe(JSON.stringify(start + 100 * 1000))

await vi.advanceTimersByTimeAsync(999)
document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
expect(localStorage.getItem('lastActive')).toBe(JSON.stringify(start + 100 * 1000))

await vi.advanceTimersByTimeAsync(1)
document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
expect(localStorage.getItem('lastActive')).toBe(JSON.stringify(start + 101 * 1000))
})

it('stores the first activity right after the page load', async () => {
const start = Date.now()
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
initSessionHeartBeat()

await vi.advanceTimersByTimeAsync(500)
document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
expect(localStorage.getItem('lastActive')).toBe(JSON.stringify(start + 500))
})

it('counts activity that is not stored for other tabs yet', async () => {
const { initSessionHeartBeat } = await import('../../session-heartbeat.ts')
initSessionHeartBeat()

await vi.advanceTimersByTimeAsync(99500)
document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
await vi.advanceTimersByTimeAsync(999)
document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))

// The logout for the stored activity would be due at 220 s, the one for the latest activity at 221 s
await vi.advanceTimersByTimeAsync(220000 - 100499)
expect(window.location.href).toBe(pageUrl)

await vi.advanceTimersByTimeAsync(1000)
expect(window.location.href).toBe(logoutUrl)
})

it('counts activity when the browser storage is not available', async () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('The quota has been exceeded.', 'QuotaExceededError')
})
const onError = vi.fn()
window.addEventListener('error', onError)

await expectActivity(() => document.body.dispatchEvent(new MouseEvent('mousemove', { bubbles: true })))
expect(onError).not.toHaveBeenCalled()
})
})
})
Loading