Cap persisted browser history payloads (#1822)
This commit is contained in:
parent
dbeb89436a
commit
b0e7fe94ab
|
|
@ -6,6 +6,7 @@ import { writeFileSync, readFileSync, rmSync, mkdtempSync, mkdirSync } from 'fs'
|
|||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import type { Repo, TerminalTab, WorkspaceSessionState } from '../shared/types'
|
||||
import { MAX_BROWSER_HISTORY_ENTRIES } from '../shared/workspace-session-browser-history'
|
||||
|
||||
// Shared mutable state so the electron mock can reference a per-test directory
|
||||
const testState = { dir: '' }
|
||||
|
|
@ -113,6 +114,23 @@ function makeSessionWithTerminalBuffers(): WorkspaceSessionState {
|
|||
}
|
||||
}
|
||||
|
||||
function makeSessionWithBrowserHistory(count: number): WorkspaceSessionState {
|
||||
return {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
browserUrlHistory: Array.from({ length: count }, (_, index) => ({
|
||||
url: `https://example.com/${index}`,
|
||||
normalizedUrl: `https://example.com/${index}`,
|
||||
title: `Example ${index} ${'x'.repeat(200)}`,
|
||||
lastVisitedAt: 1_700_000_000_000 - index,
|
||||
visitCount: 1
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
describe('Store', () => {
|
||||
beforeEach(() => {
|
||||
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
|
||||
|
|
@ -1037,6 +1055,20 @@ describe('Store', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('caps oversized browser history when setting workspace session', async () => {
|
||||
const store = await createStore()
|
||||
const oversizedSession = makeSessionWithBrowserHistory(500)
|
||||
const oversizedBytes = Buffer.byteLength(JSON.stringify(oversizedSession))
|
||||
|
||||
store.setWorkspaceSession(oversizedSession)
|
||||
|
||||
const session = store.getWorkspaceSession()
|
||||
const prunedBytes = Buffer.byteLength(JSON.stringify(session))
|
||||
expect(session.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES)
|
||||
expect(session.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199')
|
||||
expect(prunedBytes).toBeLessThan(oversizedBytes / 2)
|
||||
})
|
||||
|
||||
it('keeps terminal scrollback buffers when the repo catalog is not hydrated yet', async () => {
|
||||
const store = await createStore()
|
||||
|
||||
|
|
@ -1092,6 +1124,23 @@ describe('Store', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('caps oversized legacy browser history when loading workspace session', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: makeSessionWithBrowserHistory(500)
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
const session = store.getWorkspaceSession()
|
||||
expect(session.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES)
|
||||
expect(session.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199')
|
||||
})
|
||||
|
||||
it('does not restore cleared SSH bindings after a lease expired', async () => {
|
||||
const store = await createStore()
|
||||
store.upsertSshRemotePtyLease({
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ import {
|
|||
} from '../shared/constants'
|
||||
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
|
||||
import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers'
|
||||
import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history'
|
||||
import { getRepoIdFromWorktreeId } from '../shared/worktree-id'
|
||||
|
||||
function encrypt(plaintext: string): string {
|
||||
|
|
@ -464,7 +465,9 @@ export class Store {
|
|||
|
||||
result = {
|
||||
...result,
|
||||
workspaceSession: pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos)
|
||||
workspaceSession: pruneWorkspaceSessionBrowserHistory(
|
||||
pruneLocalTerminalScrollbackBuffers(result.workspaceSession, result.repos)
|
||||
)
|
||||
}
|
||||
|
||||
return this.migrateTelemetry(result, fileExistedOnLoad)
|
||||
|
|
@ -1100,7 +1103,9 @@ export class Store {
|
|||
}
|
||||
|
||||
setWorkspaceSession(session: PersistedState['workspaceSession']): void {
|
||||
session = pruneLocalTerminalScrollbackBuffers(session, this.state.repos)
|
||||
session = pruneWorkspaceSessionBrowserHistory(
|
||||
pruneLocalTerminalScrollbackBuffers(session, this.state.repos)
|
||||
)
|
||||
|
||||
// Why: closes the second half of the SIGKILL race (Issue #217). The
|
||||
// renderer's debounced session writer captures its state BEFORE pty:spawn
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { BrowserHistoryEntry } from '../../../shared/types'
|
||||
import { MAX_BROWSER_HISTORY_ENTRIES } from '../../../shared/workspace-session-browser-history'
|
||||
import { buildWorkspaceSessionPayload, type WorkspaceSessionSnapshot } from './workspace-session'
|
||||
|
||||
function createSnapshot(browserUrlHistory: BrowserHistoryEntry[]): WorkspaceSessionSnapshot {
|
||||
return {
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
activeTabIdByWorktree: {},
|
||||
openFiles: [],
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
browserPagesByWorkspace: {},
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
browserUrlHistory,
|
||||
unifiedTabsByWorktree: {},
|
||||
groupsByWorktree: {},
|
||||
layoutByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
sshConnectionStates: new Map(),
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
lastKnownRelayPtyIdByTabId: {},
|
||||
lastVisitedAtByWorktreeId: {}
|
||||
}
|
||||
}
|
||||
|
||||
describe('workspace session browser history payloads', () => {
|
||||
it('caps history and reduces serialized bytes at the payload boundary', () => {
|
||||
const oversizedHistory = Array.from({ length: 500 }, (_, index) => ({
|
||||
url: `https://example.com/${index}`,
|
||||
normalizedUrl: `https://example.com/${index}`,
|
||||
title: `${'Search result '.repeat(40)}${index}`,
|
||||
lastVisitedAt: 1_700_000_000_000 - index,
|
||||
visitCount: 1
|
||||
}))
|
||||
const uncappedPayload = {
|
||||
...buildWorkspaceSessionPayload(createSnapshot([])),
|
||||
browserUrlHistory: oversizedHistory
|
||||
}
|
||||
const payload = buildWorkspaceSessionPayload(createSnapshot(oversizedHistory))
|
||||
|
||||
expect(payload.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES)
|
||||
expect(payload.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199')
|
||||
expect(Buffer.byteLength(JSON.stringify(payload))).toBeLessThan(
|
||||
Buffer.byteLength(JSON.stringify(uncappedPayload)) / 2
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -82,6 +82,7 @@ function createSnapshot(overrides: Partial<AppState> = {}): AppState {
|
|||
}
|
||||
]
|
||||
},
|
||||
browserUrlHistory: [],
|
||||
...overrides
|
||||
} as AppState
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
WorkspaceVisibleTabType
|
||||
} from '../../../shared/types'
|
||||
import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-session-terminal-buffers'
|
||||
import { normalizeBrowserHistoryEntries } from '../../../shared/workspace-session-browser-history'
|
||||
import type { AppState } from '../store'
|
||||
import type { OpenFile } from '../store/slices/editor'
|
||||
|
||||
|
|
@ -264,7 +265,10 @@ export function buildWorkspaceSessionPayload(
|
|||
snapshot.browserPagesByWorkspace,
|
||||
snapshot.activeBrowserTabIdByWorktree
|
||||
),
|
||||
browserUrlHistory: snapshot.browserUrlHistory,
|
||||
// Why: browser history is user-lifetime state. Enforce the storage cap at
|
||||
// the payload boundary so stale renderer state cannot make every session
|
||||
// write stringify an oversized legacy history array.
|
||||
browserUrlHistory: normalizeBrowserHistoryEntries(snapshot.browserUrlHistory),
|
||||
unifiedTabs: unifiedTabsByWorktree,
|
||||
tabGroups: groupsByWorktree,
|
||||
tabGroupLayouts: layoutByWorktree,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ import type {
|
|||
} from '../../../../shared/types'
|
||||
import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
|
||||
import { redactKagiSessionToken } from '../../../../shared/browser-url'
|
||||
import {
|
||||
MAX_BROWSER_HISTORY_ENTRIES,
|
||||
normalizeBrowserHistoryEntries,
|
||||
normalizeBrowserHistoryUrl
|
||||
} from '../../../../shared/workspace-session-browser-history'
|
||||
import { pickNeighbor } from './tab-group-state'
|
||||
import { destroyWorkspaceWebviews } from './browser-webview-cleanup'
|
||||
|
||||
|
|
@ -138,38 +143,6 @@ export type BrowserSlice = {
|
|||
setDefaultBrowserSessionProfileId: (profileId: string | null) => void
|
||||
}
|
||||
|
||||
const MAX_BROWSER_HISTORY_ENTRIES = 200
|
||||
|
||||
function normalizeHistoryUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(redactKagiSessionToken(url))
|
||||
parsed.hostname = parsed.hostname.toLowerCase()
|
||||
parsed.protocol = parsed.protocol.toLowerCase()
|
||||
let normalized = parsed.toString()
|
||||
if (normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1)
|
||||
}
|
||||
return normalized
|
||||
} catch {
|
||||
return redactKagiSessionToken(url).toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
function deduplicateHistory(entries: BrowserHistoryEntry[]): BrowserHistoryEntry[] {
|
||||
const seen = new Set<string>()
|
||||
const deduped: BrowserHistoryEntry[] = []
|
||||
for (const entry of entries) {
|
||||
const safeUrl = redactKagiSessionToken(entry.url)
|
||||
const key = normalizeHistoryUrl(safeUrl)
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
deduped.push({ ...entry, url: safeUrl, normalizedUrl: key })
|
||||
}
|
||||
return deduped.slice(0, MAX_BROWSER_HISTORY_ENTRIES)
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
const trimmed = url.trim()
|
||||
if (trimmed.length === 0) {
|
||||
|
|
@ -1268,7 +1241,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
activeBrowserTabId,
|
||||
activeTabTypeByWorktree: nextActiveTabTypeByWorktree,
|
||||
activeTabType,
|
||||
browserUrlHistory: deduplicateHistory(session.browserUrlHistory ?? [])
|
||||
browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? [])
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -1494,7 +1467,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
|||
if (safeUrl === ORCA_BROWSER_BLANK_URL || safeUrl === 'about:blank' || !safeUrl) {
|
||||
return
|
||||
}
|
||||
const normalized = normalizeHistoryUrl(safeUrl)
|
||||
const normalized = normalizeBrowserHistoryUrl(safeUrl)
|
||||
set((s) => {
|
||||
const existing = s.browserUrlHistory.find((entry) => entry.normalizedUrl === normalized)
|
||||
let next: BrowserHistoryEntry[] = existing
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import type { BrowserHistoryEntry, WorkspaceSessionState } from './types'
|
||||
import { redactKagiSessionToken } from './browser-url'
|
||||
|
||||
export const MAX_BROWSER_HISTORY_ENTRIES = 200
|
||||
|
||||
export function normalizeBrowserHistoryUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(redactKagiSessionToken(url))
|
||||
parsed.hostname = parsed.hostname.toLowerCase()
|
||||
parsed.protocol = parsed.protocol.toLowerCase()
|
||||
let normalized = parsed.toString()
|
||||
if (normalized.endsWith('/')) {
|
||||
normalized = normalized.slice(0, -1)
|
||||
}
|
||||
return normalized
|
||||
} catch {
|
||||
return redactKagiSessionToken(url).toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeBrowserHistoryEntries(
|
||||
entries: readonly BrowserHistoryEntry[]
|
||||
): BrowserHistoryEntry[] {
|
||||
const seen = new Set<string>()
|
||||
const normalizedEntries: BrowserHistoryEntry[] = []
|
||||
for (const entry of entries) {
|
||||
const safeUrl = redactKagiSessionToken(entry.url)
|
||||
const key = normalizeBrowserHistoryUrl(safeUrl)
|
||||
if (seen.has(key)) {
|
||||
continue
|
||||
}
|
||||
seen.add(key)
|
||||
normalizedEntries.push({ ...entry, url: safeUrl, normalizedUrl: key })
|
||||
if (normalizedEntries.length >= MAX_BROWSER_HISTORY_ENTRIES) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return normalizedEntries
|
||||
}
|
||||
|
||||
export function pruneWorkspaceSessionBrowserHistory(
|
||||
session: WorkspaceSessionState
|
||||
): WorkspaceSessionState {
|
||||
if (!session.browserUrlHistory) {
|
||||
return session
|
||||
}
|
||||
const browserUrlHistory = normalizeBrowserHistoryEntries(session.browserUrlHistory)
|
||||
if (
|
||||
browserUrlHistory.length === session.browserUrlHistory.length &&
|
||||
browserUrlHistory.every((entry, index) => entry === session.browserUrlHistory?.[index])
|
||||
) {
|
||||
return session
|
||||
}
|
||||
return { ...session, browserUrlHistory }
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { parseWorkspaceSession } from './workspace-session-schema'
|
||||
import { MAX_BROWSER_HISTORY_ENTRIES } from './workspace-session-browser-history'
|
||||
|
||||
describe('parseWorkspaceSession', () => {
|
||||
it('accepts a minimal valid session', () => {
|
||||
|
|
@ -116,4 +117,27 @@ describe('parseWorkspaceSession', () => {
|
|||
expect(result.value.lastVisitedAtByWorktreeId).toEqual({ good: 1_700_000_000_000 })
|
||||
}
|
||||
})
|
||||
|
||||
it('caps oversized browser history while parsing legacy workspace sessions', () => {
|
||||
const result = parseWorkspaceSession({
|
||||
activeRepoId: null,
|
||||
activeWorktreeId: null,
|
||||
activeTabId: null,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {},
|
||||
browserUrlHistory: Array.from({ length: 500 }, (_, index) => ({
|
||||
url: `https://example.com/${index}`,
|
||||
normalizedUrl: `https://example.com/${index}`,
|
||||
title: `Example ${index}`,
|
||||
lastVisitedAt: 1_700_000_000_000 - index,
|
||||
visitCount: 1
|
||||
}))
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) {
|
||||
expect(result.value.browserUrlHistory).toHaveLength(MAX_BROWSER_HISTORY_ENTRIES)
|
||||
expect(result.value.browserUrlHistory?.at(-1)?.url).toBe('https://example.com/199')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
TerminalPaneLayoutNode,
|
||||
WorkspaceSessionState
|
||||
} from './types'
|
||||
import { normalizeBrowserHistoryEntries } from './workspace-session-browser-history'
|
||||
|
||||
// ─── Terminal pane layout (recursive) ───────────────────────────────
|
||||
|
||||
|
|
@ -182,6 +183,10 @@ const browserHistoryEntrySchema = z.object({
|
|||
visitCount: z.number()
|
||||
})
|
||||
|
||||
const browserHistoryEntriesSchema = z
|
||||
.array(browserHistoryEntrySchema)
|
||||
.transform((entries) => normalizeBrowserHistoryEntries(entries))
|
||||
|
||||
// ─── Workspace session ──────────────────────────────────────────────
|
||||
|
||||
export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.object({
|
||||
|
|
@ -197,7 +202,7 @@ export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.o
|
|||
browserPagesByWorkspace: z.record(z.string(), z.array(browserPageSchema)).optional(),
|
||||
activeBrowserTabIdByWorktree: z.record(z.string(), z.string().nullable()).optional(),
|
||||
activeTabTypeByWorktree: z.record(z.string(), workspaceVisibleTabTypeSchema).optional(),
|
||||
browserUrlHistory: z.array(browserHistoryEntrySchema).optional(),
|
||||
browserUrlHistory: browserHistoryEntriesSchema.optional(),
|
||||
activeTabIdByWorktree: z.record(z.string(), z.string().nullable()).optional(),
|
||||
unifiedTabs: z.record(z.string(), z.array(tabSchema)).optional(),
|
||||
tabGroups: z.record(z.string(), z.array(tabGroupSchema)).optional(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue