perf: optimize session loading and desktop startup (#2545)
* perf: optimize session loading and startup * fix(chat): stabilize history prepend virtualization * perf: unblock first session open from startup network contention Opening the first session after app start waited seconds for its message fetch. Three independent contributors, each measured via CDP network capture and Chromium net-log against the packaged desktop app: - The active-session watchdog fired an uncapped per-directory status poll and child-session discovery burst at startup, and other subsystems (git checks, global session pages, command/skill discovery) fanned out alongside it, saturating the browser's ~6 HTTP/1.1 sockets per origin. Add a shared background-network gate (concurrency 3) and route the watchdog, poll-shaped git reads (also priority: low), global session pages, command/skill loads, and the background update check through it. - The packaged renderer is cross-origin to the loopback backend, so every API call needs a CORS preflight; a few slow OpenCode-proxied requests held the whole pool while preflights and interactive traffic queued behind them. Lift Chromium's per-host connection cap for loopback via ignore-connections-limit in the Electron shell. - OpenCode initializes each directory lazily on its first request, so the first click paid that cost interactively. Warm the last-used directory and the three most recently opened projects right after OpenCode readiness, sequentially and best-effort, overlapping UI startup. Validation: new background-network tests, lifecycle warmup test, focused store/sync tests, UI type-check and lint, dead-code report, node --check plus electron type-check/lint, and CDP first-open measurements on the packaged app (message fetch socket queue 5.4s -> 0.03s). * fix(ui): keep interactive git reads out of background queue --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
09f0c64839
commit
aae889b904
@@ -128,6 +128,8 @@ Cross-directory selectors subscribe to the narrow child-store field they aggrega
|
||||
|
||||
Session display order is independent from streaming-frequency `time.updated` publications. `session-ordering.ts` promotes a session exactly when its authoritative activity phase crosses `settled` (`idle`/`error`) and `active` (`busy`/`retry`) in either direction. Repeated busy/retry or idle/error events are no-ops. The first authoritative status snapshot establishes a baseline without synthetic promotions; later snapshots reconcile missed transitions. Root sessions compare lifecycle rank only with other roots, while child sessions compare lifecycle rank only with siblings sharing the same `parentID`, so child activity never moves its root conversation. Pins remain the first ordering bucket. The timestamp/creation fallback is frozen when a session first participates in ordering, so later metadata-only updates cannot reorder it; creation time and ID provide deterministic ties. Runtime switches clear all phases, baselines, and ranks.
|
||||
|
||||
The active-session watchdog in `sync-context.tsx` (per-directory status polls and child-session discovery lists) runs its network calls through the shared background-network gate in `@/lib/background-network`, alongside poll-shaped git reads, global session pages, and command/skill discovery. Background fan-out must stay under that gate so the browser's per-origin connection pool keeps free sockets for interactive traffic — an uncapped startup burst previously queued the first session-open message fetch for seconds.
|
||||
|
||||
Imperative cross-directory session lookups use the cached ID index from `getAllSyncSessionMap()`. The index is rebuilt only when a child store's `state.session` reference changes; permission lineage checks must reuse it instead of rebuilding a full session map per call.
|
||||
|
||||
VS Code does not run the server permission-auto-accept runtime. The extension host persists and broadcasts authoritative policy, while its foreground UI runtime resolves missing child-session lineage through the OpenCode API before deciding whether to suppress and answer a `permission.asked` event. Enabling the policy and reconnect/bootstrap both reconcile pending requests in the session directory, including requests inherited by child sessions. Unknown lineage and exhausted reply retries fail closed and leave the request available for manual action. A later `permission.replied` event invalidates any older deferred ask so the async policy check cannot resurrect a resolved request. With every OpenChamber webview closed or suspended no responder runs; this is an intentional VS Code limitation. Other runtimes remain fully server-owned.
|
||||
@@ -164,15 +166,16 @@ Rules:
|
||||
4. Async commits are generation-checked. Runtime switches, forced refreshes, eviction, and disposal must reject stale completion.
|
||||
5. Prefetch coverage and persisted directory data are runtime-scoped. Legacy persisted directory entries may seed startup continuity, but they are not live truth.
|
||||
6. Message and part materialization preserves references for unchanged records and maintains direct message-to-parts lookup. Consumers subscribe to the selected session's records rather than broad message/part containers.
|
||||
7. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work.
|
||||
7. Pagination demand must carry the selected session's effective directory. It must not fall back to the sync provider directory because the visible session may belong to another worktree.
|
||||
8. The ref-stable loader is disposed only after the current task when its provider unmounts. This lets React Strict Mode's development setup → cleanup → setup probe retain a usable loader for child effects, while real disposal still invalidates the preceding lifecycle's work.
|
||||
|
||||
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Older pages are fetched through the same loader and merged with optimistic records before publication.
|
||||
Initial loads use smaller pages on constrained VS Code/mobile surfaces. Prefetch resolves only the initial renderable page; it does not eagerly download older history. The mounted chat timeline requests older pages when its viewport is underfilled or the user scrolls toward history, while mobile uses its explicit load-older action. Timeline caches, pending work, prepend snapshots, and stale checks use runtime + directory + session identity so equal session IDs in different worktrees cannot share lifecycle state. Older pages are fetched through the same loader and merged with optimistic records before publication.
|
||||
|
||||
## Loading diagnostics
|
||||
|
||||
Session loading instrumentation is disabled by default. Set `localStorage.openchamber_session_load_perf` to `"1"`, reproduce the interaction, then inspect `window.__openchamberSessionLoadPerformance.events`.
|
||||
|
||||
The bounded event buffer records bootstrap, message, and global-list operations with queue/duration, caller, outcome, retry count, and record count where applicable. Instrumentation is diagnostic only; unit/type/lint checks do not replace production runtime profiling at representative project/session scale.
|
||||
The bounded event buffer records only controlled bootstrap, message, and global-list operation/caller labels with queue/duration, outcome, retry count, and downloaded record count where applicable. Message-page events also record the requested limit and whether a cursor was present. When diagnostics are enabled, the selected chat records its first painted renderable message snapshot once per recent session identity and immediately clears the corresponding browser performance entry after emitting the trace mark. Canceled frames retain no measured identity, so returning to that session can schedule a replacement measurement; completed identity tracking uses the same 1,000-entry ceiling as the event buffer. Exported events never retain runtime keys, directories, session IDs, credentials, or message content. Initial-message expansion counts every downloaded page, not only the accepted page. The browser profiler independently validates the known labels and finite numeric fields before export. Instrumentation is diagnostic only; unit/type/lint checks do not replace production runtime profiling at representative project/session scale.
|
||||
|
||||
High-frequency sync diagnostics are separately disabled by default. Set `localStorage.openchamber_sync_perf` to `"1"` before reload to enable fixed numeric counters for pipeline traffic, reducer publications, streaming reconciliations, entries/messages visited, targeted heartbeat work, and persistence serialization/write volume. The hot path performs only a null check while disabled; counters never retain IDs, payloads, or user content.
|
||||
|
||||
|
||||
@@ -525,7 +525,6 @@ export class ChildStoreManager {
|
||||
this.notifyBootstrapSubscribers()
|
||||
const finishPerformanceEvent = startSessionLoadPerformanceEvent({
|
||||
operation: "bootstrap.directory",
|
||||
directory: next.directory,
|
||||
caller: next.reason,
|
||||
queuedMs: Math.max(0, Date.now() - next.enqueuedAt),
|
||||
})
|
||||
|
||||
@@ -1,15 +1,51 @@
|
||||
const STORAGE_KEY = "openchamber_session_load_perf"
|
||||
const MAX_EVENTS = 1_000
|
||||
const ALLOWED_OPERATIONS = new Set([
|
||||
"bootstrap.directory",
|
||||
"bootstrap.sessions.all",
|
||||
"bootstrap.sessions.archived",
|
||||
"bootstrap.sessions.roots",
|
||||
"global-sessions.active",
|
||||
"global-sessions.archived",
|
||||
"session-messages.initial",
|
||||
"session-messages.older",
|
||||
"session-messages.page",
|
||||
"session-messages.refresh",
|
||||
"session-messages.visible",
|
||||
"session-prefetch",
|
||||
])
|
||||
const ALLOWED_CALLERS = new Set([
|
||||
"action-demand",
|
||||
"current-directory",
|
||||
"initial",
|
||||
"initial-page",
|
||||
"known-project",
|
||||
"known-worktree",
|
||||
"older",
|
||||
"pagination",
|
||||
"prefetch",
|
||||
"project-expanded",
|
||||
"refresh",
|
||||
"selected-session",
|
||||
"server-connected",
|
||||
"worktree-expanded",
|
||||
])
|
||||
const ALLOWED_OUTCOMES = new Set<SessionLoadPerformanceOutcome>([
|
||||
"complete",
|
||||
"error",
|
||||
"stale",
|
||||
"deduplicated",
|
||||
"canceled",
|
||||
])
|
||||
|
||||
type SessionLoadPerformanceOutcome = "complete" | "error" | "stale" | "deduplicated" | "canceled"
|
||||
|
||||
type SessionLoadPerformanceEvent = {
|
||||
operation: string
|
||||
runtimeKey?: string
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
caller?: string
|
||||
queuedMs?: number
|
||||
requestLimit?: number
|
||||
cursorPresent?: boolean
|
||||
durationMs: number
|
||||
outcome: SessionLoadPerformanceOutcome
|
||||
retryCount?: number
|
||||
@@ -27,7 +63,7 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
const enabled = (): boolean => {
|
||||
const isSessionLoadPerformanceEnabled = (): boolean => {
|
||||
if (typeof window === "undefined") return false
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === "1"
|
||||
@@ -40,23 +76,100 @@ const now = (): number => typeof performance !== "undefined" && typeof performan
|
||||
? performance.now()
|
||||
: Date.now()
|
||||
|
||||
const nonNegativeNumber = (value: unknown): number | undefined => (
|
||||
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined
|
||||
)
|
||||
const nonNegativeInteger = (value: unknown): number | undefined => (
|
||||
Number.isInteger(value) && Number(value) >= 0 ? Number(value) : undefined
|
||||
)
|
||||
|
||||
export function startSessionLoadPerformanceEvent(input: Omit<SessionLoadPerformanceEvent, "at" | "durationMs" | "outcome">) {
|
||||
if (!enabled()) return () => undefined
|
||||
if (
|
||||
!isSessionLoadPerformanceEnabled()
|
||||
|| !ALLOWED_OPERATIONS.has(input.operation)
|
||||
|| (input.caller !== undefined && !ALLOWED_CALLERS.has(input.caller))
|
||||
) return () => undefined
|
||||
const startedAt = now()
|
||||
return (
|
||||
outcome: SessionLoadPerformanceOutcome,
|
||||
details?: Partial<Pick<SessionLoadPerformanceEvent, "retryCount" | "recordCount">>,
|
||||
) => {
|
||||
if (typeof window === "undefined") return
|
||||
if (typeof window === "undefined" || !ALLOWED_OUTCOMES.has(outcome)) return
|
||||
const state = window.__openchamberSessionLoadPerformance ?? { events: [] }
|
||||
const queuedMs = nonNegativeNumber(input.queuedMs)
|
||||
const requestLimit = nonNegativeInteger(input.requestLimit)
|
||||
const retryCount = nonNegativeInteger(details?.retryCount ?? input.retryCount)
|
||||
const recordCount = nonNegativeInteger(details?.recordCount ?? input.recordCount)
|
||||
state.events.push({
|
||||
...input,
|
||||
...details,
|
||||
operation: input.operation,
|
||||
...(input.caller !== undefined ? { caller: input.caller } : {}),
|
||||
...(queuedMs !== undefined ? { queuedMs } : {}),
|
||||
...(requestLimit !== undefined ? { requestLimit } : {}),
|
||||
...(typeof input.cursorPresent === "boolean" ? { cursorPresent: input.cursorPresent } : {}),
|
||||
outcome,
|
||||
durationMs: Math.max(0, now() - startedAt),
|
||||
...(retryCount !== undefined ? { retryCount } : {}),
|
||||
...(recordCount !== undefined ? { recordCount } : {}),
|
||||
at: Date.now(),
|
||||
})
|
||||
if (state.events.length > MAX_EVENTS) state.events.splice(0, state.events.length - MAX_EVENTS)
|
||||
window.__openchamberSessionLoadPerformance = state
|
||||
}
|
||||
}
|
||||
|
||||
type FirstVisibleSessionPerformanceDependencies = {
|
||||
enabled: () => boolean
|
||||
requestFrame: (callback: FrameRequestCallback) => number
|
||||
cancelFrame: (frame: number) => void
|
||||
markVisible: () => void
|
||||
startEvent: typeof startSessionLoadPerformanceEvent
|
||||
}
|
||||
|
||||
const FIRST_VISIBLE_MARK = "openchamber.chat.first_message_visible"
|
||||
|
||||
export function createFirstVisibleSessionPerformanceTracker(
|
||||
dependencies?: Partial<FirstVisibleSessionPerformanceDependencies>,
|
||||
) {
|
||||
const enabled = dependencies?.enabled ?? isSessionLoadPerformanceEnabled
|
||||
const requestFrame = dependencies?.requestFrame ?? ((callback) => window.requestAnimationFrame(callback))
|
||||
const cancelFrame = dependencies?.cancelFrame ?? ((frame) => window.cancelAnimationFrame(frame))
|
||||
const markVisible = dependencies?.markVisible ?? (() => {
|
||||
performance.mark(FIRST_VISIBLE_MARK)
|
||||
performance.clearMarks(FIRST_VISIBLE_MARK)
|
||||
})
|
||||
const startEvent = dependencies?.startEvent ?? startSessionLoadPerformanceEvent
|
||||
const measuredKeys = new Set<string>()
|
||||
let pending: { key: string; frame: number } | null = null
|
||||
|
||||
return {
|
||||
schedule(key: string, recordCount: number): () => void {
|
||||
if (!enabled() || measuredKeys.has(key)) return () => undefined
|
||||
if (pending) {
|
||||
cancelFrame(pending.frame)
|
||||
pending = null
|
||||
}
|
||||
const finishPerformanceEvent = startEvent({
|
||||
operation: "session-messages.visible",
|
||||
caller: "selected-session",
|
||||
recordCount,
|
||||
})
|
||||
const frame = requestFrame(() => {
|
||||
if (pending?.key !== key || pending.frame !== frame) return
|
||||
pending = null
|
||||
measuredKeys.add(key)
|
||||
if (measuredKeys.size > MAX_EVENTS) {
|
||||
measuredKeys.delete(measuredKeys.values().next().value!)
|
||||
}
|
||||
markVisible()
|
||||
finishPerformanceEvent("complete")
|
||||
})
|
||||
pending = { key, frame }
|
||||
|
||||
return () => {
|
||||
if (pending?.key !== key || pending.frame !== frame) return
|
||||
cancelFrame(frame)
|
||||
pending = null
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { ChildStoreManager } from "./child-store"
|
||||
import { SessionMessageLoader } from "./session-message-loader"
|
||||
import {
|
||||
createFirstVisibleSessionPerformanceTracker,
|
||||
startSessionLoadPerformanceEvent,
|
||||
} from "./session-load-performance"
|
||||
|
||||
const createRecord = (sessionID: string, id = "msg_1") => ({
|
||||
info: { id, sessionID, role: "user", time: { created: 1 } } as Message,
|
||||
@@ -56,6 +60,34 @@ describe("SessionMessageLoader", () => {
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("leaves older history loading to explicit viewport demand", async () => {
|
||||
const calls: Array<{ limit?: number; before?: string }> = []
|
||||
const { childStores, loader } = createLoader(async ({ sessionID, limit, before }) => {
|
||||
calls.push({ limit, before })
|
||||
return before
|
||||
? response([createRecord(sessionID, "msg_older")])
|
||||
: response([createRecord(sessionID, "msg_latest")], "older-cursor")
|
||||
})
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
|
||||
await loader.ensure(target, { reason: "prefetch" })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(calls).toEqual([{ limit: 50, before: undefined }])
|
||||
expect(loader.getSnapshot(target).cursor).toBe("older-cursor")
|
||||
|
||||
await loader.loadOlder(target)
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ limit: 50, before: undefined },
|
||||
{ limit: 100, before: "older-cursor" },
|
||||
])
|
||||
expect(childStores.getChild(target.directory)?.getState().message[target.sessionID]?.map((message) => message.id))
|
||||
.toEqual(["msg_latest", "msg_older"].sort())
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("runs a requested tail refresh after an older in-flight load", async () => {
|
||||
const initial = deferred<ReturnType<typeof response>>()
|
||||
const refresh = deferred<ReturnType<typeof response>>()
|
||||
@@ -128,6 +160,34 @@ describe("SessionMessageLoader", () => {
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("loads older history with the selected directory's cursor for duplicate session IDs", async () => {
|
||||
const providerDirectory = "/repo/provider"
|
||||
const selectedDirectory = "/repo/selected-worktree"
|
||||
const sessionID = "shared"
|
||||
const calls: Array<{ directory?: string; before?: string }> = []
|
||||
const { childStores, loader } = createLoader(async ({ directory, before }) => {
|
||||
calls.push({ directory, before })
|
||||
return before
|
||||
? response([createRecord(sessionID, `older-${directory}`)])
|
||||
: response([createRecord(sessionID, `latest-${directory}`)], `${directory}-cursor`)
|
||||
})
|
||||
|
||||
await Promise.all([
|
||||
loader.ensure({ directory: providerDirectory, sessionID }),
|
||||
loader.ensure({ directory: selectedDirectory, sessionID }),
|
||||
])
|
||||
calls.length = 0
|
||||
|
||||
await loader.loadOlder({ directory: selectedDirectory, sessionID })
|
||||
|
||||
expect(calls).toEqual([{
|
||||
directory: selectedDirectory,
|
||||
before: `${selectedDirectory}-cursor`,
|
||||
}])
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("exposes a retryable error without clearing an existing snapshot", async () => {
|
||||
let fail = true
|
||||
const { childStores, loader } = createLoader(async ({ sessionID }) => {
|
||||
@@ -209,4 +269,165 @@ describe("SessionMessageLoader", () => {
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
})
|
||||
|
||||
test("reports retries and every downloaded initial expansion record", async () => {
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window")
|
||||
const diagnosticWindow = {
|
||||
location: { search: "" },
|
||||
localStorage: {
|
||||
getItem: (key: string) => key === "openchamber_session_load_perf" ? "1" : null,
|
||||
},
|
||||
} as unknown as Window
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: diagnosticWindow })
|
||||
|
||||
const target = { directory: "/repo", sessionID: "session-a" }
|
||||
let calls = 0
|
||||
const { childStores, loader } = createLoader(async () => {
|
||||
calls += 1
|
||||
if (calls === 1) return {}
|
||||
if (calls === 2) {
|
||||
const assistant = createRecord(target.sessionID, "msg_assistant")
|
||||
assistant.info = { ...assistant.info, role: "assistant" } as Message
|
||||
return response([assistant], "older")
|
||||
}
|
||||
return response([createRecord(target.sessionID, "msg_user")])
|
||||
})
|
||||
|
||||
try {
|
||||
await loader.ensure(target)
|
||||
|
||||
const events = diagnosticWindow.__openchamberSessionLoadPerformance?.events ?? []
|
||||
const initialEvent = events.find((event) => event.operation === "session-messages.initial")
|
||||
const pageEvents = events.filter((event) => event.operation === "session-messages.page")
|
||||
expect(calls).toBe(3)
|
||||
expect(pageEvents.map((event) => event.requestLimit)).toEqual([50, 100])
|
||||
expect(pageEvents.map((event) => event.cursorPresent)).toEqual([false, false])
|
||||
expect(pageEvents.map((event) => event.recordCount)).toEqual([1, 1])
|
||||
expect(initialEvent?.outcome).toBe("complete")
|
||||
expect(initialEvent?.retryCount).toBe(1)
|
||||
expect(initialEvent?.recordCount).toBe(2)
|
||||
expect("runtimeKey" in initialEvent!).toBe(false)
|
||||
expect("directory" in initialEvent!).toBe(false)
|
||||
expect("sessionID" in initialEvent!).toBe(false)
|
||||
} finally {
|
||||
loader.dispose()
|
||||
childStores.disposeAll()
|
||||
if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow)
|
||||
else Reflect.deleteProperty(globalThis, "window")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("session load performance diagnostics", () => {
|
||||
test("rejects unknown raw labels and preserves approved input counts", () => {
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window")
|
||||
const diagnosticWindow = {
|
||||
localStorage: {
|
||||
getItem: (key: string) => key === "openchamber_session_load_perf" ? "1" : null,
|
||||
},
|
||||
} as unknown as Window
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: diagnosticWindow })
|
||||
|
||||
try {
|
||||
const finishUnknown = startSessionLoadPerformanceEvent({
|
||||
operation: "secret-operation",
|
||||
caller: "secret-caller",
|
||||
recordCount: 999,
|
||||
})
|
||||
finishUnknown("complete")
|
||||
const finishVisible = startSessionLoadPerformanceEvent({
|
||||
operation: "session-messages.visible",
|
||||
caller: "selected-session",
|
||||
recordCount: 30,
|
||||
})
|
||||
finishVisible("complete")
|
||||
|
||||
expect(diagnosticWindow.__openchamberSessionLoadPerformance?.events).toHaveLength(1)
|
||||
const event = diagnosticWindow.__openchamberSessionLoadPerformance?.events[0]
|
||||
expect(event?.operation).toBe("session-messages.visible")
|
||||
expect(event?.caller).toBe("selected-session")
|
||||
expect(event?.recordCount).toBe(30)
|
||||
expect(JSON.stringify(diagnosticWindow.__openchamberSessionLoadPerformance)).not.toContain("secret")
|
||||
} finally {
|
||||
if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow)
|
||||
else Reflect.deleteProperty(globalThis, "window")
|
||||
}
|
||||
})
|
||||
|
||||
test("does not schedule visibility work while diagnostics are disabled", () => {
|
||||
let requestedFrames = 0
|
||||
let visibleMarks = 0
|
||||
const tracker = createFirstVisibleSessionPerformanceTracker({
|
||||
enabled: () => false,
|
||||
requestFrame: () => {
|
||||
requestedFrames += 1
|
||||
return 1
|
||||
},
|
||||
cancelFrame: () => undefined,
|
||||
markVisible: () => {
|
||||
visibleMarks += 1
|
||||
},
|
||||
})
|
||||
|
||||
tracker.schedule("session-a", 10)
|
||||
|
||||
expect(requestedFrames).toBe(0)
|
||||
expect(visibleMarks).toBe(0)
|
||||
})
|
||||
|
||||
test("reschedules an identity when its pending visibility frame was canceled", () => {
|
||||
let nextFrame = 0
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
const marks: string[] = []
|
||||
const tracker = createFirstVisibleSessionPerformanceTracker({
|
||||
enabled: () => true,
|
||||
requestFrame: (callback) => {
|
||||
nextFrame += 1
|
||||
frames.set(nextFrame, callback)
|
||||
return nextFrame
|
||||
},
|
||||
cancelFrame: (frame) => {
|
||||
frames.delete(frame)
|
||||
},
|
||||
markVisible: () => marks.push("visible"),
|
||||
startEvent: () => () => undefined,
|
||||
})
|
||||
|
||||
const cancelFirstA = tracker.schedule("session-a", 10)
|
||||
cancelFirstA()
|
||||
const cancelB = tracker.schedule("session-b", 10)
|
||||
cancelB()
|
||||
tracker.schedule("session-a", 10)
|
||||
frames.get(3)?.(0)
|
||||
|
||||
expect(marks).toEqual(["visible"])
|
||||
})
|
||||
|
||||
test("does not remeasure a completed identity after another session", () => {
|
||||
let nextFrame = 0
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
const marks: string[] = []
|
||||
const tracker = createFirstVisibleSessionPerformanceTracker({
|
||||
enabled: () => true,
|
||||
requestFrame: (callback) => {
|
||||
nextFrame += 1
|
||||
frames.set(nextFrame, callback)
|
||||
return nextFrame
|
||||
},
|
||||
cancelFrame: (frame) => {
|
||||
frames.delete(frame)
|
||||
},
|
||||
markVisible: () => marks.push("visible"),
|
||||
startEvent: () => () => undefined,
|
||||
})
|
||||
|
||||
tracker.schedule("session-a", 10)
|
||||
frames.get(1)?.(0)
|
||||
tracker.schedule("session-b", 10)
|
||||
frames.get(2)?.(0)
|
||||
tracker.schedule("session-a", 10)
|
||||
|
||||
expect(nextFrame).toBe(2)
|
||||
expect(marks).toEqual(["visible", "visible"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -61,6 +61,11 @@ type FetchedPage = {
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
type LoadPerformanceDetails = {
|
||||
retryCount: number
|
||||
recordCount: number
|
||||
}
|
||||
|
||||
type LoaderConfiguration = {
|
||||
sdk: OpencodeClient
|
||||
runtimeKey: string
|
||||
@@ -201,15 +206,8 @@ export class SessionMessageLoader {
|
||||
}
|
||||
if (options?.force) this.bumpGeneration(entry)
|
||||
const kind: SessionMessageLoadKind = options?.reason === "prefetch" ? "prefetch" : "initial"
|
||||
return this.startLoad(normalized, entry, store, kind, async (isCurrent) => {
|
||||
await this.loadInitial(normalized, entry, store, isCurrent)
|
||||
if (!isMobileSurfaceRuntime() && isCurrent()) {
|
||||
queueMicrotask(() => {
|
||||
if (isCurrent() && entry.snapshot.cursor && !entry.snapshot.complete) {
|
||||
void this.loadOlder(normalized)
|
||||
}
|
||||
})
|
||||
}
|
||||
return this.startLoad(normalized, entry, store, kind, async (isCurrent, performance) => {
|
||||
await this.loadInitial(normalized, entry, store, isCurrent, performance)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -225,8 +223,8 @@ export class SessionMessageLoader {
|
||||
if (entry.snapshot.complete || !entry.snapshot.cursor) return Promise.resolve()
|
||||
const store = this.childStores.ensureChild(normalized.directory, { bootstrap: false })
|
||||
const cursor = entry.snapshot.cursor
|
||||
return this.startLoad(normalized, entry, store, "older", async (isCurrent) => {
|
||||
const page = await this.fetchPage(normalized, HISTORY_MESSAGE_PAGE_SIZE, cursor)
|
||||
return this.startLoad(normalized, entry, store, "older", async (isCurrent, performance) => {
|
||||
const page = await this.fetchPage(normalized, HISTORY_MESSAGE_PAGE_SIZE, cursor, "older", performance)
|
||||
if (!isCurrent()) return
|
||||
const committed = this.commitPage(normalized, entry, store, page, "prepend", isCurrent)
|
||||
if (!committed || !isCurrent()) return
|
||||
@@ -279,11 +277,11 @@ export class SessionMessageLoader {
|
||||
}
|
||||
const store = this.childStores.ensureChild(normalized.directory, { bootstrap: false })
|
||||
this.bumpGeneration(entry)
|
||||
return this.startLoad(normalized, entry, store, "refresh", async (isCurrent) => {
|
||||
return this.startLoad(normalized, entry, store, "refresh", async (isCurrent, performance) => {
|
||||
const previousCoverage = entry.snapshot.resolved
|
||||
? { cursor: entry.snapshot.cursor, complete: entry.snapshot.complete }
|
||||
: null
|
||||
const page = await this.fetchPage(normalized, Math.max(1, limit))
|
||||
const page = await this.fetchPage(normalized, Math.max(1, limit), undefined, "refresh", performance)
|
||||
if (!isCurrent()) return
|
||||
const committed = this.commitPage(normalized, entry, store, page, "merge", isCurrent)
|
||||
if (!committed || !isCurrent()) return
|
||||
@@ -455,15 +453,12 @@ export class SessionMessageLoader {
|
||||
entry: LoaderEntry,
|
||||
store: { getState: () => DirectoryStore; setState: DirectoryStoreSetter },
|
||||
kind: SessionMessageLoadKind,
|
||||
run: (isCurrent: () => boolean) => Promise<void>,
|
||||
run: (isCurrent: () => boolean, performance: LoadPerformanceDetails) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const generation = entry.snapshot.generation
|
||||
const sdkEpoch = this.sdkEpoch
|
||||
const finishPerformanceEvent = startSessionLoadPerformanceEvent({
|
||||
operation: kind === "prefetch" ? "session-prefetch" : `session-messages.${kind}`,
|
||||
runtimeKey: this.runtimeKey,
|
||||
directory: target.directory,
|
||||
sessionID: target.sessionID,
|
||||
caller: kind,
|
||||
})
|
||||
const isCurrent = () => (
|
||||
@@ -472,21 +467,22 @@ export class SessionMessageLoader {
|
||||
&& entry.snapshot.generation === generation
|
||||
&& this.childStores.getChild(target.directory) === store
|
||||
)
|
||||
const performance = { retryCount: 0, recordCount: 0 }
|
||||
this.patchEntry(entry, { status: "loading", loadingKind: kind, error: null })
|
||||
let loadPromise: Promise<void>
|
||||
try {
|
||||
loadPromise = run(isCurrent)
|
||||
loadPromise = run(isCurrent, performance)
|
||||
} catch (error) {
|
||||
loadPromise = Promise.reject(error)
|
||||
}
|
||||
const promise = loadPromise
|
||||
.then(() => finishPerformanceEvent(isCurrent() ? "complete" : "stale"))
|
||||
.then(() => finishPerformanceEvent(isCurrent() ? "complete" : "stale", performance))
|
||||
.catch((error: unknown) => {
|
||||
if (!isCurrent()) {
|
||||
finishPerformanceEvent("stale")
|
||||
finishPerformanceEvent("stale", performance)
|
||||
return
|
||||
}
|
||||
finishPerformanceEvent("error")
|
||||
finishPerformanceEvent("error", performance)
|
||||
this.patchEntry(entry, {
|
||||
status: "error",
|
||||
loadingKind: null,
|
||||
@@ -505,10 +501,11 @@ export class SessionMessageLoader {
|
||||
entry: LoaderEntry,
|
||||
store: { getState: () => DirectoryStore; setState: DirectoryStoreSetter },
|
||||
isCurrent: () => boolean,
|
||||
performance?: LoadPerformanceDetails,
|
||||
): Promise<void> {
|
||||
const storeMessageCount = store.getState().message[target.sessionID]?.length ?? 0
|
||||
const firstLimit = Math.max(entry.snapshot.limit, storeMessageCount, getInitialPageSize())
|
||||
const firstPage = await this.fetchPage(target, firstLimit)
|
||||
const firstPage = await this.fetchPage(target, firstLimit, undefined, "initial-page", performance)
|
||||
if (!isCurrent()) return
|
||||
const deferFirstCommit = !firstPage.complete && !hasUserMessage(firstPage.session)
|
||||
let committed = deferFirstCommit
|
||||
@@ -519,7 +516,7 @@ export class SessionMessageLoader {
|
||||
if (deferFirstCommit) {
|
||||
for (const limit of getInitialExpansionLimits()) {
|
||||
if (limit <= firstLimit || !isCurrent()) continue
|
||||
const expandedPage = await this.fetchPage(target, limit)
|
||||
const expandedPage = await this.fetchPage(target, limit, undefined, "initial-page", performance)
|
||||
if (!isCurrent()) return
|
||||
acceptedPage = expandedPage
|
||||
const boundaryFound = hasUserMessage(expandedPage.session)
|
||||
@@ -547,32 +544,58 @@ export class SessionMessageLoader {
|
||||
this.persistCoverage(target, entry.snapshot)
|
||||
}
|
||||
|
||||
private async fetchPage(target: SessionMessageTarget, limit: number, before?: string): Promise<FetchedPage> {
|
||||
const result = await retry(async () => {
|
||||
const response = await this.sdk.session.messages({
|
||||
sessionID: target.sessionID,
|
||||
directory: target.directory,
|
||||
limit,
|
||||
before,
|
||||
})
|
||||
assertSdkSuccess(response, "session.messages")
|
||||
if (!Array.isArray(response.data)) {
|
||||
const error = new Error("session.messages returned no data") as Error & { status?: number }
|
||||
error.status = 503
|
||||
throw error
|
||||
}
|
||||
return { data: response.data, response: response.response }
|
||||
private async fetchPage(
|
||||
target: SessionMessageTarget,
|
||||
limit: number,
|
||||
before?: string,
|
||||
caller: "initial-page" | "older" | "refresh" = "initial-page",
|
||||
performance?: LoadPerformanceDetails,
|
||||
): Promise<FetchedPage> {
|
||||
const finishPagePerformance = startSessionLoadPerformanceEvent({
|
||||
operation: "session-messages.page",
|
||||
caller,
|
||||
requestLimit: limit,
|
||||
cursorPresent: before !== undefined,
|
||||
})
|
||||
const records = result.data.filter((record: { info?: { id?: string } }) => Boolean(record?.info?.id))
|
||||
const session = records
|
||||
.map((record: { info: Message }) => stripMessageDiffSnapshots(record.info))
|
||||
.sort((left: Message, right: Message) => cmp(left.id, right.id))
|
||||
const partsByMessageID = new Map<string, Part[]>()
|
||||
for (const record of records as Array<{ info: { id: string }; parts?: Part[] }>) {
|
||||
partsByMessageID.set(record.info.id, sortParts(record.parts ?? []))
|
||||
let attempts = 0
|
||||
let recordCount = 0
|
||||
try {
|
||||
const result = await retry(async () => {
|
||||
attempts += 1
|
||||
const response = await this.sdk.session.messages({
|
||||
sessionID: target.sessionID,
|
||||
directory: target.directory,
|
||||
limit,
|
||||
before,
|
||||
})
|
||||
assertSdkSuccess(response, "session.messages")
|
||||
const data = response.data
|
||||
if (!Array.isArray(data)) {
|
||||
const error = new Error("session.messages returned no data") as Error & { status?: number }
|
||||
error.status = 503
|
||||
throw error
|
||||
}
|
||||
return { data, response: response.response }
|
||||
})
|
||||
const records = result.data.filter((record: { info?: { id?: string } }) => Boolean(record?.info?.id))
|
||||
recordCount = records.length
|
||||
if (performance) performance.recordCount += recordCount
|
||||
const session = records
|
||||
.map((record: { info: Message }) => stripMessageDiffSnapshots(record.info))
|
||||
.sort((left: Message, right: Message) => cmp(left.id, right.id))
|
||||
const partsByMessageID = new Map<string, Part[]>()
|
||||
for (const record of records as Array<{ info: { id: string }; parts?: Part[] }>) {
|
||||
partsByMessageID.set(record.info.id, sortParts(record.parts ?? []))
|
||||
}
|
||||
const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined
|
||||
finishPagePerformance("complete", { retryCount: Math.max(0, attempts - 1), recordCount })
|
||||
return { session, partsByMessageID, cursor, complete: !cursor }
|
||||
} catch (error) {
|
||||
finishPagePerformance("error", { retryCount: Math.max(0, attempts - 1), recordCount })
|
||||
throw error
|
||||
} finally {
|
||||
if (performance) performance.retryCount += Math.max(0, attempts - 1)
|
||||
}
|
||||
const cursor = result.response?.headers?.get?.("x-next-cursor") ?? undefined
|
||||
return { session, partsByMessageID, cursor, complete: !cursor }
|
||||
}
|
||||
|
||||
private commitPage(
|
||||
|
||||
@@ -31,6 +31,7 @@ import { bootstrapGlobal, bootstrapDirectory } from "./bootstrap"
|
||||
import { retry } from "./retry"
|
||||
import { touchStreamingSession, updateChangedStreamingSessions, updateStreamingState } from "./streaming"
|
||||
import { countSyncPerformance } from "./performance-diagnostics"
|
||||
import { runBackgroundNetworkTask } from "@/lib/background-network"
|
||||
import { setActionRefs } from "./session-actions"
|
||||
import { setSyncRefs, getAllSyncSessions } from "./sync-refs"
|
||||
import { stripSessionDiffSnapshots } from "./sanitize"
|
||||
@@ -242,6 +243,16 @@ const ACTIVE_SESSION_STATUS_POLL_INTERVAL_MS = 5_000
|
||||
const ACTIVE_SESSION_STALE_EVENT_MS = 20_000
|
||||
const ACTIVE_SESSION_FULL_RESYNC_COOLDOWN_MS = 15_000
|
||||
const CHILD_SESSION_DISCOVERY_INTERVAL_MS = 15_000
|
||||
|
||||
// Active-session watchdog network calls run under the shared
|
||||
// background-network gate (see lib/background-network.ts). The watchdog walks
|
||||
// every initialized child store each tick and fires a status poll plus a
|
||||
// child-session discovery list per directory with active candidates — on
|
||||
// startup with many cache-hydrated directories that is dozens of simultaneous
|
||||
// requests, which would otherwise queue interactive traffic (opening a
|
||||
// session) behind them on the browser's ~6 sockets per origin. Later ticks
|
||||
// still cover every directory via the per-directory timestamps.
|
||||
|
||||
const requestSignature = (items: Array<{ id: string }> | undefined): string => {
|
||||
if (!items || items.length === 0) return ""
|
||||
return items
|
||||
@@ -2072,7 +2083,7 @@ export function SyncProvider(props: {
|
||||
if (parentSessionIds.length === 0) return
|
||||
try {
|
||||
const scopedClient = opencodeClient.getScopedSdkClient(directory)
|
||||
const result = await scopedClient.session.list({ directory, limit: 200 })
|
||||
const result: unknown = await runBackgroundNetworkTask(() => scopedClient.session.list({ directory, limit: 200 }))
|
||||
const allSessions = ((result as { data?: unknown }).data ?? []) as Session[]
|
||||
const state = store.getState()
|
||||
const existingIds = new Set(state.session.map((s) => s.id))
|
||||
@@ -2121,7 +2132,7 @@ export function SyncProvider(props: {
|
||||
polling.add(directory)
|
||||
try {
|
||||
const before = store.getState()
|
||||
const statuses = await resyncDirectorySessionStatuses(directory, store, candidateSessionIds, "monotonic")
|
||||
const statuses = await runBackgroundNetworkTask(() => resyncDirectorySessionStatuses(directory, store, candidateSessionIds, "monotonic"))
|
||||
if (!statuses) return
|
||||
const needsSnapshot = candidateSessionIds.some((sessionId) => (
|
||||
needsSnapshotAfterStatusPoll(before, sessionId, statuses[sessionId])
|
||||
|
||||
@@ -313,12 +313,11 @@ export function useSync() {
|
||||
|
||||
// Load more (pagination)
|
||||
const loadMore = useCallback(
|
||||
async (sessionID: string, directoryOverride?: string) => {
|
||||
const targetDirectory = directoryOverride || directory
|
||||
async (sessionID: string, targetDirectory: string) => {
|
||||
touch(sessionID, targetDirectory)
|
||||
await messageLoader.loadOlder({ directory: targetDirectory, sessionID })
|
||||
},
|
||||
[directory, messageLoader, touch],
|
||||
[messageLoader, touch],
|
||||
)
|
||||
|
||||
const prefetchSession = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user